-
-
Notifications
You must be signed in to change notification settings - Fork 72
Add Settings API and more #331
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughAdds a new Settings REST API endpoint; implements Pay-What-You-Want pricing and per-product custom amounts/recurring; introduces billing-period restrictions for discount codes; updates checkout/payment validation to skip billing when payment isn't required; large docs/hook-generator and e2e test/fixture adjustments. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client/Vue
participant API as Settings Endpoint
participant Auth as API Auth
participant Store as Settings Store
participant Logger as Maybe Logger
Client->>API: Request (GET/POST/PUT/PATCH /settings...)
API->>Auth: verify permissions
Auth-->>API: allowed / denied
alt allowed
API->>Store: read or update settings
Store-->>API: payload / result
API->>API: filter_sensitive_settings & validate
API->>Logger: maybe_log_api_call(request)
API-->>Client: 200 JSON or summary
else denied
API-->>Client: 403 WP_Error
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
inc/functions/helper.php (1)
498-625: Fix array union operator with numeric keys to include all intended tags.The array union operator (
+) preserves the left-hand array's values for overlapping numeric keys. Sincearray_keys($allowed_html)returns numeric keys[0, 1, ...]and the extra tags array also has numeric keys[0, 1, 2, ...], the+operator will drop extra tags that occupy indices already present inarray_keys()(specifically 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input'). Usearray_merge()instead to concatenate the arrays and ensure all tags are included.🔧 Suggested fix
- ] + array_merge_recursive($allowed_html, array_fill_keys(array_keys($allowed_html) + ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'], $vue_and_data_attributes)); + ] + array_merge_recursive( + $allowed_html, + array_fill_keys( + array_unique(array_merge(array_keys($allowed_html), ['div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'ul', 'ol', 'li', 'a', 'img', 'input', 'textarea'])), + $vue_and_data_attributes + ) + );
🤖 Fix all issues with AI agents
In `@inc/apis/class-settings-endpoint.php`:
- Around line 258-270: The loop that calls wu_save_setting() with raw $value
should sanitize/validate via the Field API first: implement a helper (e.g.
sanitize_setting_value(string $key, $value)) that iterates
Settings::get_instance()->get_sections(), finds the field definition for $key,
constructs a \WP_Ultimo\UI\Field($key, $field_def), calls
$field->set_value($value) and returns $field->get_value(); then replace the
direct calls to wu_save_setting($key, $value) in the foreach over
$filtered_settings (and the similar persistence block later) to call $sanitized
= sanitize_setting_value($key, $value) and either pass $sanitized to
wu_save_setting or skip/reject unknown keys (returning null) as appropriate.
- Around line 426-436: The maybe_log_api_call method currently logs raw request
bodies via $request->get_body() which can leak sensitive data; before building
the $payload (or before calling wp_json_encode) sanitize the body by removing or
replacing sensitive keys (e.g., "password", "token", "authorization", "api_key",
"credit_card", "ssn") or omit the body entirely, e.g., call a helper like
redact_request_body($request->get_body()) and use that redacted result for the
'body_params' value so wp_json_encode(wu_log_add(...)) never receives plaintext
secrets; update maybe_log_api_call to perform this redaction and ensure
get_body()/body_params references the sanitized output.
In `@inc/class-settings.php`:
- Around line 711-720: The privacy-policy link in the description passed to
add_field for 'enable_error_reporting' lacks rel="noopener noreferrer" and the
URL isn't escaped; update the sprintf call building the description so the <a
href="%s" ...> uses esc_url( 'https://ultimatemultisite.com/privacy-policy/' )
and add rel="noopener noreferrer" to the anchor (i.e., modify the description
string for add_field('general','enable_error_reporting', ...) to include
rel="noopener noreferrer" and wrap the URL with esc_url()).
In `@readme.txt`:
- Around line 243-249: The changelog contains duplicate "Version [2.4.10]"
entries (the headers "Version [2.4.10]" and the block dated "2026-XX-XX"); merge
the two blocks into a single 2.4.10 entry combining both bullet points, and
replace the placeholder date "2026-XX-XX" with a concrete release date (e.g.,
"2026-01-25") or label the entry "Unreleased" if it isn't finalized; update the
single "Version [2.4.10]" header and remove the redundant header/block so only
one consolidated 2.4.10 section remains.
🧹 Nitpick comments (1)
inc/apis/class-settings-endpoint.php (1)
409-412: Drop unused$valueto satisfy PHPMDLine 409 declares
$valuebut never uses it. Usingforeach (array_keys(...))or$key => $_avoids the PHPMD warning.🧹 Suggested tidy‑up
- foreach ($settings as $key => $value) { + foreach ($settings as $key => $_) { if ($this->is_sensitive_setting($key)) { unset($settings[ $key ]); } }
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
inc/models/class-discount-code.php (1)
500-569: Billing-period restrictions can be bypassed when discount codes are first applied to cart.The
set_discount_code()method (line 1374) validates without duration/unit parameters, allowing a discount code withlimit_billing_periodsenabled to pass validation. The complete validation only occurs later inapply_discounts_to_item()(line 2415) when product-specific data is available. If callers expect billing period restrictions to be enforced immediately when adding a coupon, this creates a bypass. Consider either deferring coupon acceptance until line-item application or enforcing a hard failure inis_valid()whenlimit_billing_periodsis enabled but no duration params are provided.
🤖 Fix all issues with AI agents
In `@inc/admin-pages/class-discount-code-edit-admin-page.php`:
- Around line 532-588: get_available_billing_periods currently skips products
based solely on $product->is_recurring(), which misses PWYW products with
pwyw_recurring_mode == 'force_recurring'; update the recurrence check in
get_available_billing_periods to treat a product as recurring if
$product->is_recurring() OR ($product->get_pwyw_recurring_mode() ===
'force_recurring') (or the equivalent accessor/name used on the Product class),
then continue using $product->get_duration(), $product->get_duration_unit(),
get_price_variations(), and
Discount_Code::get_billing_period_key/parse_billing_period_key as before so PWYW
forced-recurring products are included in the $periods list and sorting.
In `@inc/admin-pages/class-membership-list-admin-page.php`:
- Around line 233-237: The billing_period parsing accepts untrusted input:
ensure $duration is clamped to >=1 and $duration_unit is validated against a
whitelist before using it to build the cart; specifically, after parsing
$billing_period (the code that sets $billing_parts, $duration and
$duration_unit) replace the loose values with a sanitized $duration = max(1,
absint($billing_parts[0] ?? 1)) and $duration_unit only set if in an allowed
array (e.g. ['day','week','month','year']) otherwise default to 'month' so
invalid units or zero durations cannot create bad variations in the cart.
In `@inc/checkout/class-checkout.php`:
- Around line 1621-1633: process_order() builds the cart params without
including the PWYW fields so submitted custom amounts are lost; update the cart
construction inside process_order() to read the same keys as create_order()—use
$this->request_or_session('custom_amounts', []) and
$this->request_or_session('pwyw_recurring', []) (and propagate them into the
'custom_amounts' and 'pwyw_recurring' keys of the cart array) so the cart passed
to order creation retains the PWYW values.
In `@inc/sso/class-magic-link.php`:
- Around line 167-190: Before generating a token in
generate_cross_network_magic_link, validate that the user actually has access to
the target site and that the redirect URL is safe: call
verify_user_site_access($user_id, $site_id) (or equivalent membership check) and
return false if it fails, then if $redirect_to is provided validate it with
filter_var($redirect_to, FILTER_VALIDATE_URL) and ensure its host matches the
parsed host of $site_url (using parse_url) to prevent mismatched/remote
redirects; only after these checks succeed proceed to generate_token() and
persist $token_data so you do not consume tokens for invalid requests.
In `@views/legacy/signup/steps/step-domain-url-preview.php`:
- Around line 38-40: The subdirectory branch uses a hardcoded 'yoursite'
fallback in the sprintf for the element with id "wu-your-site" (v-html="site_url
? site_url : 'yoursite'"); replace that literal with the translatable
$dynamic_part so both branches use the same localized fallback: update the
sprintf format and the v-html fallback to reference $dynamic_part (escaped via
esc_html) rather than the hardcoded string, keeping the same element ids
"wu-your-site" and "wu-site-domain" and preserving proper escaping and quoting.
🧹 Nitpick comments (1)
inc/sso/class-magic-link.php (1)
201-205: Keep URL filter parity withgenerate_magic_link().This method skips the
wu_magic_link_urlfilter, so downstream customizations won’t apply to cross-network links. Consider applying the same filter for consistency and extensibility.🔧 Proposed change
- return add_query_arg( + $magic_link = add_query_arg( [self::TOKEN_QUERY_ARG => $token], $site_url ); + + return apply_filters('wu_magic_link_url', $magic_link, $user_id, $site_id, $redirect_to);
| protected function get_available_billing_periods() { | ||
|
|
||
| $periods = []; | ||
|
|
||
| foreach (wu_get_products() as $product) { | ||
| if ( ! $product->is_recurring()) { | ||
| continue; | ||
| } | ||
|
|
||
| $duration = $product->get_duration(); | ||
| $duration_unit = $product->get_duration_unit(); | ||
| $period_key = Discount_Code::get_billing_period_key($duration, $duration_unit); | ||
|
|
||
| if ( ! isset($periods[ $period_key ])) { | ||
| $periods[ $period_key ] = $this->format_billing_period_label($duration, $duration_unit); | ||
| } | ||
|
|
||
| // Also check for price variations | ||
| $price_variations = $product->get_price_variations(); | ||
|
|
||
| if ( ! empty($price_variations)) { | ||
| foreach ($price_variations as $variation) { | ||
| $var_duration = isset($variation['duration']) ? (int) $variation['duration'] : 0; | ||
| $var_duration_unit = isset($variation['duration_unit']) ? $variation['duration_unit'] : ''; | ||
|
|
||
| if ($var_duration > 0 && ! empty($var_duration_unit)) { | ||
| $var_period_key = Discount_Code::get_billing_period_key($var_duration, $var_duration_unit); | ||
|
|
||
| if ( ! isset($periods[ $var_period_key ])) { | ||
| $periods[ $var_period_key ] = $this->format_billing_period_label($var_duration, $var_duration_unit); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Sort by duration for consistent display | ||
| uksort( | ||
| $periods, | ||
| function ($a, $b) { | ||
| $a_parts = Discount_Code::parse_billing_period_key($a); | ||
| $b_parts = Discount_Code::parse_billing_period_key($b); | ||
|
|
||
| if ( ! $a_parts || ! $b_parts) { | ||
| return 0; | ||
| } | ||
|
|
||
| // Convert to days for comparison | ||
| $a_days = $this->get_period_in_days($a_parts['duration'], $a_parts['duration_unit']); | ||
| $b_days = $this->get_period_in_days($b_parts['duration'], $b_parts['duration_unit']); | ||
|
|
||
| return $a_days <=> $b_days; | ||
| } | ||
| ); | ||
|
|
||
| return $periods; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for is_recurring, pay_what_you_want, and pwyw patterns
rg -n "is_recurring\(|pay_what_you_want|pwyw_recurring_mode" -A 2 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 33826
🏁 Script executed:
# Also search for the Product class definition and is_recurring method
rg -n "class.*Product|function is_recurring|def is_recurring" -A 5 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 14004
🏁 Script executed:
# Check how PWYW products are represented and handled
rg -n "is_pay_what_you_want|pwyw|pay.what.you.want" -i -A 3 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 30256
🏁 Script executed:
# Search for how get_available_billing_periods is used
rg -n "get_available_billing_periods" -B 2 -A 5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1616
🏁 Script executed:
# Check if discount codes have special handling for PWYW products
rg -n "class.*Discount.*Code|is_pay_what_you_want.*discount" -i -A 3 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 50394
🏁 Script executed:
# Look at the discount code model to understand what billing periods are used for
fd "class-discount" --type f | head -5 | xargs catRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 36757
PWYW recurring products with force_recurring mode won't appear in available billing periods.
The method relies on is_recurring() which checks the recurring flag and amount > 0, but doesn't account for pwyw_recurring_mode. A PWYW product configured with force_recurring may have recurring=false, causing is_recurring() to return false and the product to be skipped entirely. This means its billing period information never reaches the discount code admin form, preventing admins from restricting codes to periods that only exist on PWYW products.
🤖 Prompt for AI Agents
In `@inc/admin-pages/class-discount-code-edit-admin-page.php` around lines 532 -
588, get_available_billing_periods currently skips products based solely on
$product->is_recurring(), which misses PWYW products with pwyw_recurring_mode ==
'force_recurring'; update the recurrence check in get_available_billing_periods
to treat a product as recurring if $product->is_recurring() OR
($product->get_pwyw_recurring_mode() === 'force_recurring') (or the equivalent
accessor/name used on the Product class), then continue using
$product->get_duration(), $product->get_duration_unit(), get_price_variations(),
and Discount_Code::get_billing_period_key/parse_billing_period_key as before so
PWYW forced-recurring products are included in the $periods list and sorting.
| // Parse the billing period into duration and duration_unit. | ||
| $billing_period = wu_request('billing_period', '1-month'); | ||
| $billing_parts = explode('-', $billing_period, 2); | ||
| $duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1; | ||
| $duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate and clamp the billing period inputs before building the cart.
billing_period comes from the request; absint() can yield 0, and duration_unit isn’t validated. This can lead to invalid variations or avoidable cart errors. Consider whitelisting units and enforcing duration >= 1.
✅ Suggested hardening
- $billing_period = wu_request('billing_period', '1-month');
- $billing_parts = explode('-', $billing_period, 2);
- $duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
- $duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
+ $billing_period = wu_request('billing_period', '1-month');
+ $billing_parts = explode('-', $billing_period, 2);
+ $duration = max(1, absint($billing_parts[0] ?? 1));
+ $duration_unit = $billing_parts[1] ?? 'month';
+ $allowed_units = ['day', 'week', 'month', 'year'];
+ if ( ! in_array($duration_unit, $allowed_units, true)) {
+ $duration_unit = 'month';
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Parse the billing period into duration and duration_unit. | |
| $billing_period = wu_request('billing_period', '1-month'); | |
| $billing_parts = explode('-', $billing_period, 2); | |
| $duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1; | |
| $duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month'; | |
| // Parse the billing period into duration and duration_unit. | |
| $billing_period = wu_request('billing_period', '1-month'); | |
| $billing_parts = explode('-', $billing_period, 2); | |
| $duration = max(1, absint($billing_parts[0] ?? 1)); | |
| $duration_unit = $billing_parts[1] ?? 'month'; | |
| $allowed_units = ['day', 'week', 'month', 'year']; | |
| if ( ! in_array($duration_unit, $allowed_units, true)) { | |
| $duration_unit = 'month'; | |
| } |
🤖 Prompt for AI Agents
In `@inc/admin-pages/class-membership-list-admin-page.php` around lines 233 - 237,
The billing_period parsing accepts untrusted input: ensure $duration is clamped
to >=1 and $duration_unit is validated against a whitelist before using it to
build the cart; specifically, after parsing $billing_period (the code that sets
$billing_parts, $duration and $duration_unit) replace the loose values with a
sanitized $duration = max(1, absint($billing_parts[0] ?? 1)) and $duration_unit
only set if in an allowed array (e.g. ['day','week','month','year']) otherwise
default to 'month' so invalid units or zero durations cannot create bad
variations in the cart.
| public function generate_cross_network_magic_link(int $user_id, int $site_id, string $site_url, string $redirect_to = '') { | ||
|
|
||
| if ( ! $this->is_enabled()) { | ||
| return false; | ||
| } | ||
|
|
||
| $user = get_userdata($user_id); | ||
|
|
||
| if ( ! $user) { | ||
| return false; | ||
| } | ||
|
|
||
| $token = $this->generate_token(); | ||
| $user_agent = $this->get_user_agent(); | ||
| $ip_address = $this->get_client_ip(); | ||
|
|
||
| $token_data = [ | ||
| 'user_id' => $user_id, | ||
| 'site_id' => $site_id, | ||
| 'redirect_to' => $redirect_to, | ||
| 'created_at' => time(), | ||
| 'user_agent' => $user_agent, | ||
| 'ip_address' => $ip_address, | ||
| ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate user/site access and the target URL before issuing a cross-network link.
Right now the method can return a link that will always fail later (user not a member or URL mismatched/invalid) while still consuming a token. Add verify_user_site_access() and basic URL validation (and ideally a host match) before generating the token.
🛠️ Proposed fix
public function generate_cross_network_magic_link(int $user_id, int $site_id, string $site_url, string $redirect_to = '') {
- if ( ! $this->is_enabled()) {
+ if ( ! $this->is_enabled() ) {
return false;
}
$user = get_userdata($user_id);
- if ( ! $user) {
+ if ( ! $user ) {
return false;
}
+
+ if ( ! $this->verify_user_site_access($user_id, $site_id) ) {
+ return false;
+ }
+
+ $site_url = wp_http_validate_url($site_url);
+ if ( ! $site_url ) {
+ return false;
+ }🤖 Prompt for AI Agents
In `@inc/sso/class-magic-link.php` around lines 167 - 190, Before generating a
token in generate_cross_network_magic_link, validate that the user actually has
access to the target site and that the redirect URL is safe: call
verify_user_site_access($user_id, $site_id) (or equivalent membership check) and
return false if it fails, then if $redirect_to is provided validate it with
filter_var($redirect_to, FILTER_VALIDATE_URL) and ensure its host matches the
parsed host of $site_url (using parse_url) to prevent mismatched/remote
redirects; only after these checks succeed proceed to generate_token() and
persist $token_data so you do not consume tokens for invalid requests.
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@inc/admin-pages/class-product-edit-admin-page.php`:
- Around line 384-411: The duration and duration_unit inputs inside
pwyw_duration_group are being suppressed from form submission by setting
'v-bind:name' => '""'; remove that attribute for both the 'duration' and
'duration_unit' entries so they behave like the same fields in amount_group
(i.e., allow them to retain their normal name binding and submit their values
when PWYW mode hides amount_group); locate the array keys 'duration' and
'duration_unit' in the pwyw_duration_group definition and delete the
'v-bind:name' => '""' lines (leave other html_attr entries like 'v-model'
intact).
… add hooks documentation generator - Move cart assignment before validation so should_collect_payment() is available - Remove billing field requirements when payment is not needed (free trials) - Hide billing address fields via v-show when payment not collected - Handle 'autogenerate' site_url value in checkout - Add array_filter to prevent null products in template limits (3 locations) - Add sunrise.php mapping and SUNRISE config to wp-env - Add BerlinDB dynamic hooks documentation generator to build pipeline - Update encrypted secrets and translation file Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In @.wp-env.json:
- Around line 26-27: The .wp-env.json test environment includes a non-existent
addon path "../addons/ultimate-multisite-woocommerce" (the array entries "." and
"../addons/ultimate-multisite-woocommerce"); remove the
"../addons/ultimate-multisite-woocommerce" entry from the list if the addon is
not required for tests, or add/restore the missing directory at that relative
path (ensuring the plugin contains valid plugin files) so wp-env can bootstrap
successfully; update the array in .wp-env.json accordingly and rerun the test
environment to confirm resolution.
In `@DEVELOPER-DOCUMENTATION.md`:
- Around line 1-3: The DEVELOPER-DOCUMENTATION.md was removed and replaced with
only an external link, which reduces offline availability and versioned context;
restore essential in-repo developer guidance by either (a) re-adding core
sections back into DEVELOPER-DOCUMENTATION.md (setup, architecture overview,
contribution guidelines, key workflows) and keep the external link as
“canonical” reference, or (b) consolidate those essential sections into
README.md under a “Developer Documentation” header and include the external link
and a note that it is the full canonical source; ensure the file(s) reference
the same headings and examples as the external site so reviewers have immediate,
versioned docs in-repo.
In `@inc/berlindb-dynamic-hooks.php`:
- Around line 1-17: Delete the stale duplicate generated file
berlindb-dynamic-hooks.php (the shorter 1674-line version) and leave only the
canonical generator output produced by generate-berlindb-hooks.php in the
documentation output location; remove any references to the removed file from
the repo so the generator’s documentation file is the single source of truth.
In `@lang/ultimate-multisite.pot`:
- Around line 8917-8927: The telemetry disclosure string (msgid "Allow Ultimate
Multisite to collect anonymous usage data..." used in inc/class-settings.php and
views/settings/widget-settings-body.php) is incomplete; either update that
message to enumerate all actually collected fields (MySQL version, server
software, timezone, locale, SSL status, active add-ons,
customer/product/payment/domain counts, memory limits, error file paths and
sanitized stack traces, etc.) and clarify what "error logs" contains, or change
the collection logic in the code paths that assemble telemetry to remove any
fields not currently disclosed; adjust the text in the class-settings.php/views
strings to precisely match the chosen scope and ensure the error-reporting
wording explicitly states inclusion of file paths and sanitized stack traces.
🧹 Nitpick comments (3)
inc/documentation/generate-berlindb-hooks.php (3)
52-67: Consider adding error handling forfile()failure.If
Query.phpdoesn't exist or is unreadable,file()returnsfalse, and theforeachloop will fail. While the script checks for the autoloader, it doesn't verify that$query_php_absoluteexists before callingfile().♻️ Proposed fix
function find_line_number(string $file, string $pattern): int { static $lines = null; if ($lines === null) { - $lines = file($file); + $lines = file($file); + if ($lines === false) { + fwrite(STDERR, "Warning: Could not read {$file}\n"); + $lines = []; + } } foreach ($lines as $i => $line) {
523-527: Consider verifying the file write succeeded.
file_put_contents()can returnfalseon failure, but the script always reports success. This could lead to silent failures during build.♻️ Proposed fix
$content = ob_get_clean(); -file_put_contents($output_file, $content); +$bytes = file_put_contents($output_file, $content); -fwrite(STDERR, "Written to {$output_file}\n"); +if ($bytes === false) { + fwrite(STDERR, "Error: Failed to write to {$output_file}\n"); + exit(1); +} + +fwrite(STDERR, "Written {$bytes} bytes to {$output_file}\n");
152-185: Regex parsing may struggle with complex nested structures.The pattern at line 154 (
/\[\s*(.*?)\s*\]/s) uses non-greedy matching which works for simple column definitions but may fail on deeply nested arrays or columns with array-type defaults. Since this is a documentation generator and the script gracefully skips problematic entries, this is acceptable for now.
| # Ultimate Multisite Developer Documentation | ||
|
|
||
| ## Table of Contents | ||
|
|
||
| 1. [Introduction](#introduction) | ||
| 2. [REST API Reference](#rest-api-reference) | ||
| 3. [Action Hooks Reference](#action-hooks-reference) | ||
| 4. [Filter Hooks Reference](#filter-hooks-reference) | ||
| 5. [Integration Guide](#integration-guide) | ||
| 6. [Addon Development](#addon-development) | ||
| 7. [Code Examples](#code-examples) | ||
|
|
||
| --- | ||
|
|
||
| ## Introduction | ||
|
|
||
| This comprehensive guide provides developers with everything needed to integrate with, extend, or develop addons for Ultimate Multisite. Ultimate Multisite transforms a WordPress Multisite network into a Website-as-a-Service (WaaS) platform. | ||
|
|
||
| ### Key Features for Developers: | ||
| - **REST API** - Complete CRUD operations for all entities | ||
| - **Action Hooks** - 200+ hooks for lifecycle events | ||
| - **Filter Hooks** - 280+ filters for customization | ||
| - **Addon Framework** - Structured addon development system | ||
| - **Authentication** - API key-based authentication system | ||
|
|
||
| ### Requirements: | ||
| - WordPress Multisite installation | ||
| - PHP 7.4 or higher | ||
| - WP Ultimo plugin activated | ||
|
|
||
| --- | ||
|
|
||
| ## REST API Reference | ||
|
|
||
| ### Base Configuration | ||
|
|
||
| **Base URL:** `{site_url}/wp-json/wu/v2/` | ||
| **Authentication:** API Key & Secret (HTTP Basic Auth or URL Parameters) | ||
|
|
||
| ### Authentication | ||
|
|
||
| #### Enable API | ||
| ```php | ||
| // Enable API in WP Ultimo settings or programmatically | ||
| wu_save_setting('enable_api', true); | ||
| ``` | ||
|
|
||
| #### Get API Credentials | ||
| ```php | ||
| $api_key = wu_get_setting('api_key'); | ||
| $api_secret = wu_get_setting('api_secret'); | ||
| ``` | ||
|
|
||
| #### Authentication Methods | ||
|
|
||
| **HTTP Basic Auth (Recommended):** | ||
| ```bash | ||
| curl -u "api_key:api_secret" https://yoursite.com/wp-json/wu/v2/customers | ||
| ``` | ||
|
|
||
| **URL Parameters:** | ||
| ```bash | ||
| curl "https://yoursite.com/wp-json/wu/v2/customers?api_key=your_key&api_secret=your_secret" | ||
| ``` | ||
|
|
||
| ### Core Endpoints | ||
|
|
||
| #### 1. Customers API | ||
|
|
||
| **Base Route:** `/customers` | ||
|
|
||
| **Get All Customers** | ||
| ```http | ||
| GET /wu/v2/customers | ||
| ``` | ||
|
|
||
| **Get Single Customer** | ||
| ```http | ||
| GET /wu/v2/customers/{id} | ||
| ``` | ||
|
|
||
| **Create Customer** | ||
| ```http | ||
| POST /wu/v2/customers | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "user_id": 123, | ||
| "email_verification": "verified", | ||
| "type": "customer", | ||
| "has_trialed": false, | ||
| "vip": false | ||
| } | ||
| ``` | ||
|
|
||
| **Update Customer** | ||
| ```http | ||
| PUT /wu/v2/customers/{id} | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "vip": true, | ||
| "extra_information": "VIP customer notes" | ||
| } | ||
| ``` | ||
|
|
||
| **Delete Customer** | ||
| ```http | ||
| DELETE /wu/v2/customers/{id} | ||
| ``` | ||
|
|
||
| #### 2. Sites API | ||
|
|
||
| **Base Route:** `/sites` | ||
|
|
||
| **Create Site** | ||
| ```http | ||
| POST /wu/v2/sites | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "customer_id": 5, | ||
| "membership_id": 10, | ||
| "domain": "example.com", | ||
| "path": "/", | ||
| "title": "My New Site", | ||
| "template_id": 1, | ||
| "type": "customer_owned" | ||
| } | ||
| ``` | ||
|
|
||
| #### 3. Memberships API | ||
|
|
||
| **Base Route:** `/memberships` | ||
|
|
||
| **Create Membership** | ||
| ```http | ||
| POST /wu/v2/memberships | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "customer_id": 5, | ||
| "plan_id": 3, | ||
| "status": "active", | ||
| "gateway": "stripe", | ||
| "gateway_subscription_id": "sub_1234567890", | ||
| "auto_renew": true | ||
| } | ||
| ``` | ||
|
|
||
| #### 4. Products API | ||
|
|
||
| **Base Route:** `/products` | ||
|
|
||
| **Get All Products** | ||
| ```http | ||
| GET /wu/v2/products | ||
| ``` | ||
|
|
||
| #### 5. Payments API | ||
|
|
||
| **Base Route:** `/payments` | ||
|
|
||
| **Create Payment** | ||
| ```http | ||
| POST /wu/v2/payments | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "customer_id": 5, | ||
| "membership_id": 10, | ||
| "status": "completed", | ||
| "gateway": "stripe", | ||
| "gateway_payment_id": "pi_1234567890", | ||
| "total": 29.99, | ||
| "currency": "USD" | ||
| } | ||
| ``` | ||
|
|
||
| #### 6. Domains API | ||
|
|
||
| **Base Route:** `/domains` | ||
|
|
||
| **Map Domain** | ||
| ```http | ||
| POST /wu/v2/domains | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "domain": "custom-domain.com", | ||
| "customer_id": 5, | ||
| "primary_domain": 1, | ||
| "stage": "domain-mapping" | ||
| } | ||
| ``` | ||
|
|
||
| ### Registration Endpoint | ||
|
|
||
| The `/register` endpoint provides a complete checkout/registration flow: | ||
|
|
||
| ```http | ||
| POST /wu/v2/register | ||
| Content-Type: application/json | ||
|
|
||
| { | ||
| "customer": { | ||
| "username": "newuser", | ||
| "password": "securepass123", | ||
| "email": "user@example.com" | ||
| }, | ||
| "products": ["basic-plan"], | ||
| "duration": 1, | ||
| "duration_unit": "month", | ||
| "auto_renew": true, | ||
| "site": { | ||
| "site_url": "mynewsite", | ||
| "site_title": "My New Site", | ||
| "template_id": 1 | ||
| }, | ||
| "payment": { | ||
| "status": "completed" | ||
| }, | ||
| "membership": { | ||
| "status": "active" | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| **Response:** | ||
| ```json | ||
| { | ||
| "customer": { ... }, | ||
| "membership": { ... }, | ||
| "payment": { ... }, | ||
| "site": { "id": 123 } | ||
| } | ||
| ``` | ||
|
|
||
| ### Error Responses | ||
|
|
||
| ```json | ||
| { | ||
| "code": "wu_rest_invalid_parameter", | ||
| "message": "Invalid parameter value", | ||
| "data": { | ||
| "status": 400, | ||
| "params": { | ||
| "email": "Invalid email format" | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Pagination and Filtering | ||
|
|
||
| **Query Parameters:** | ||
| ```http | ||
| GET /wu/v2/customers?per_page=20&page=2&search=john&status=active | ||
| ``` | ||
|
|
||
| Common parameters: | ||
| - `per_page` - Items per page (default: 20, max: 100) | ||
| - `page` - Page number | ||
| - `search` - Search term | ||
| - `orderby` - Sort field | ||
| - `order` - Sort direction (asc/desc) | ||
| - `status` - Filter by status | ||
| - `date_created` - Filter by date range | ||
|
|
||
| --- | ||
|
|
||
| ## Action Hooks Reference | ||
|
|
||
| ### Lifecycle Hooks | ||
|
|
||
| #### Plugin Activation | ||
| ```php | ||
| /** | ||
| * Fires when WP Ultimo is activated. | ||
| * | ||
| * @since 2.0.0 | ||
| */ | ||
| do_action('wu_activation'); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_activation', function() { | ||
| // Initialize custom data | ||
| add_option('my_addon_version', '1.0.0'); | ||
| }); | ||
| ``` | ||
|
|
||
| #### Settings Management | ||
| ```php | ||
| /** | ||
| * Fires after settings are saved. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param array $settings The settings being saved. | ||
| */ | ||
| do_action('wu_after_save_settings', $settings); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_after_save_settings', function($settings) { | ||
| if (isset($settings['enable_feature'])) { | ||
| // React to feature toggle | ||
| update_option('feature_enabled', $settings['enable_feature']); | ||
| } | ||
| }); | ||
| ``` | ||
|
|
||
| ### Customer Hooks | ||
|
|
||
| #### Customer Creation | ||
| ```php | ||
| /** | ||
| * Fires after a customer is created. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Customer $customer The customer object. | ||
| */ | ||
| do_action('wu_customer_post_create', $customer); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_customer_post_create', function($customer) { | ||
| // Send welcome email | ||
| wp_mail( | ||
| $customer->get_email(), | ||
| 'Welcome!', | ||
| 'Thanks for joining our platform!' | ||
| ); | ||
| }); | ||
| ``` | ||
|
|
||
| #### Customer Status Change | ||
| ```php | ||
| /** | ||
| * Fires when customer status changes. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Customer $customer The customer object. | ||
| * @param string $old_status Previous status. | ||
| * @param string $new_status New status. | ||
| */ | ||
| do_action('wu_customer_status_change', $customer, $old_status, $new_status); | ||
| ``` | ||
|
|
||
| ### Site Hooks | ||
|
|
||
| #### Site Creation | ||
| ```php | ||
| /** | ||
| * Fires after a site is published. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Site $site The site object. | ||
| * @param WP_Ultimo\Models\Membership $membership The associated membership. | ||
| */ | ||
| do_action('wu_site_published', $site, $membership); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_site_published', function($site, $membership) { | ||
| // Set up initial site configuration | ||
| switch_to_blog($site->get_id()); | ||
|
|
||
| // Install default plugins | ||
| activate_plugin('essential-plugin/essential-plugin.php'); | ||
|
|
||
| restore_current_blog(); | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| #### Site Template Application | ||
| ```php | ||
| /** | ||
| * Fires before applying a site template. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param int $site_id The site ID being configured. | ||
| * @param int $template_id The template being applied. | ||
| */ | ||
| do_action('wu_before_apply_template', $site_id, $template_id); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_before_apply_template', function($site_id, $template_id) { | ||
| // Custom template preparation | ||
| switch_to_blog($site_id); | ||
|
|
||
| // Set custom options based on template | ||
| if ($template_id === 5) { // E-commerce template | ||
| update_option('woocommerce_store_setup', 'complete'); | ||
| } | ||
|
|
||
| restore_current_blog(); | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| ### Membership Hooks | ||
|
|
||
| #### Membership Status Changes | ||
| ```php | ||
| /** | ||
| * Fires when membership transitions to active status. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Membership $membership The membership object. | ||
| */ | ||
| do_action('wu_membership_status_to_active', $membership); | ||
|
|
||
| /** | ||
| * Fires when membership expires. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Membership $membership The membership object. | ||
| */ | ||
| do_action('wu_membership_status_to_expired', $membership); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_membership_status_to_expired', function($membership) { | ||
| // Suspend related sites | ||
| $sites = $membership->get_sites(); | ||
| foreach ($sites as $site) { | ||
| $site->set_status('suspended'); | ||
| $site->save(); | ||
| } | ||
| }); | ||
| ``` | ||
|
|
||
| ### Payment Hooks | ||
|
|
||
| #### Payment Processing | ||
| ```php | ||
| /** | ||
| * Fires when a payment is completed. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Payment $payment The payment object. | ||
| */ | ||
| do_action('wu_payment_completed', $payment); | ||
|
|
||
| /** | ||
| * Fires when a payment fails. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Payment $payment The payment object. | ||
| * @param string $error_message The error message. | ||
| */ | ||
| do_action('wu_payment_failed', $payment, $error_message); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_payment_failed', function($payment, $error) { | ||
| // Notify administrators | ||
| $admin_email = get_option('admin_email'); | ||
| wp_mail( | ||
| $admin_email, | ||
| 'Payment Failed', | ||
| sprintf('Payment #%d failed: %s', $payment->get_id(), $error) | ||
| ); | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| ### Checkout Hooks | ||
|
|
||
| #### Checkout Flow | ||
| ```php | ||
| /** | ||
| * Fires before checkout processing begins. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Checkout\Cart $cart The cart object. | ||
| */ | ||
| do_action('wu_checkout_before_processing', $cart); | ||
|
|
||
| /** | ||
| * Fires after successful checkout completion. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Payment $payment The payment object. | ||
| * @param WP_Ultimo\Models\Customer $customer The customer object. | ||
| * @param WP_Ultimo\Models\Membership $membership The membership object. | ||
| */ | ||
| do_action('wu_checkout_completed', $payment, $customer, $membership); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_checkout_completed', function($payment, $customer, $membership) { | ||
| // Track conversion in analytics | ||
| if (function_exists('gtag')) { | ||
| gtag('event', 'purchase', [ | ||
| 'transaction_id' => $payment->get_id(), | ||
| 'value' => $payment->get_total(), | ||
| 'currency' => $payment->get_currency() | ||
| ]); | ||
| } | ||
| }, 10, 3); | ||
| ``` | ||
|
|
||
| ### Domain Hooks | ||
|
|
||
| #### Domain Management | ||
| ```php | ||
| /** | ||
| * Fires when a domain is mapped successfully. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Domain $domain The domain object. | ||
| */ | ||
| do_action('wu_domain_mapped', $domain); | ||
|
|
||
| /** | ||
| * Fires when domain SSL is verified. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param WP_Ultimo\Models\Domain $domain The domain object. | ||
| */ | ||
| do_action('wu_domain_ssl_verified', $domain); | ||
|
|
||
| // Usage Example: | ||
| add_action('wu_domain_mapped', function($domain) { | ||
| // Update CDN configuration | ||
| if (function_exists('cloudflare_update_zone')) { | ||
| cloudflare_update_zone($domain->get_domain()); | ||
| } | ||
| }); | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Filter Hooks Reference | ||
|
|
||
| ### Content and Output Filters | ||
|
|
||
| #### Customizing Output | ||
| ```php | ||
| /** | ||
| * Filters the checkout form fields. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param array $fields The form fields. | ||
| * @param WP_Ultimo\Models\Checkout_Form $form The checkout form object. | ||
| * @return array Modified fields array. | ||
| */ | ||
| $fields = apply_filters('wu_checkout_form_final_fields', $fields, $form); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_checkout_form_final_fields', function($fields, $form) { | ||
| // Add custom field | ||
| $fields['company'] = [ | ||
| 'type' => 'text', | ||
| 'title' => 'Company Name', | ||
| 'required' => true, | ||
| 'placeholder' => 'Enter company name' | ||
| ]; | ||
|
|
||
| return $fields; | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| #### Email Content | ||
| ```php | ||
| /** | ||
| * Filters email content before sending. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param string $content The email content. | ||
| * @param string $email_type The email type. | ||
| * @param WP_Ultimo\Models\Customer $customer The customer object. | ||
| * @return string Modified content. | ||
| */ | ||
| $content = apply_filters('wu_email_content', $content, $email_type, $customer); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_email_content', function($content, $type, $customer) { | ||
| if ($type === 'welcome') { | ||
| // Add custom welcome message | ||
| $content .= "\n\nSpecial offer: Use code WELCOME10 for 10% off!"; | ||
| } | ||
| return $content; | ||
| }, 10, 3); | ||
| ``` | ||
|
|
||
| ### Pricing and Cart Filters | ||
|
|
||
| #### Price Modifications | ||
| ```php | ||
| /** | ||
| * Filters the final cart total. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param float $total The cart total. | ||
| * @param WP_Ultimo\Checkout\Cart $cart The cart object. | ||
| * @return float Modified total. | ||
| */ | ||
| $total = apply_filters('wu_cart_total', $total, $cart); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_cart_total', function($total, $cart) { | ||
| $customer = $cart->get_customer(); | ||
|
|
||
| // VIP discount | ||
| if ($customer && $customer->is_vip()) { | ||
| $total = $total * 0.9; // 10% discount | ||
| } | ||
|
|
||
| return $total; | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| #### Tax Calculations | ||
| ```php | ||
| /** | ||
| * Filters tax rates by location. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param float $rate The tax rate. | ||
| * @param string $country The country code. | ||
| * @param string $state The state code. | ||
| * @return float Modified tax rate. | ||
| */ | ||
| $rate = apply_filters('wu_tax_rate', $rate, $country, $state); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_tax_rate', function($rate, $country, $state) { | ||
| // Custom tax rate for specific region | ||
| if ($country === 'US' && $state === 'CA') { | ||
| return 0.0875; // 8.75% CA tax | ||
| } | ||
|
|
||
| return $rate; | ||
| }, 10, 3); | ||
| ``` | ||
|
|
||
| ### Limitation Filters | ||
|
|
||
| #### Feature Limitations | ||
| ```php | ||
| /** | ||
| * Filters whether a feature is allowed for a site. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param bool $allowed Whether the feature is allowed. | ||
| * @param int $site_id The site ID. | ||
| * @param WP_Ultimo\Models\Membership $membership The membership object. | ||
| * @return bool Modified permission. | ||
| */ | ||
| $allowed = apply_filters('wu_limitation_feature_allowed', $allowed, $site_id, $membership); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_limitation_feature_allowed', function($allowed, $site_id, $membership) { | ||
| // Allow premium features for VIP customers | ||
| if ($membership->get_customer()->is_vip()) { | ||
| return true; | ||
| } | ||
|
|
||
| return $allowed; | ||
| }, 10, 3); | ||
| ``` | ||
|
|
||
| #### Storage Limits | ||
| ```php | ||
| /** | ||
| * Filters disk space limit for a site. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param int $limit The disk space limit in MB. | ||
| * @param int $site_id The site ID. | ||
| * @param WP_Ultimo\Models\Membership $membership The membership object. | ||
| * @return int Modified limit. | ||
| */ | ||
| $limit = apply_filters('wu_disk_space_limit', $limit, $site_id, $membership); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_disk_space_limit', function($limit, $site_id, $membership) { | ||
| // Bonus storage for long-term customers | ||
| if ($membership->get_days_active() > 365) { | ||
| $limit += 500; // Extra 500MB | ||
| } | ||
|
|
||
| return $limit; | ||
| }, 10, 3); | ||
| ``` | ||
|
|
||
| ### Gateway and Payment Filters | ||
|
|
||
| #### Gateway Selection | ||
| ```php | ||
| /** | ||
| * Filters available payment gateways. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param array $gateways Available gateways. | ||
| * @param WP_Ultimo\Checkout\Cart $cart The cart object. | ||
| * @return array Modified gateways array. | ||
| */ | ||
| $gateways = apply_filters('wu_available_gateways', $gateways, $cart); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_available_gateways', function($gateways, $cart) { | ||
| // Hide PayPal for enterprise plans | ||
| if ($cart->get_total() > 1000) { | ||
| unset($gateways['paypal']); | ||
| } | ||
|
|
||
| return $gateways; | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| ### Template and Theme Filters | ||
|
|
||
| #### Template Selection | ||
| ```php | ||
| /** | ||
| * Filters available site templates. | ||
| * | ||
| * @since 2.0.0 | ||
| * @param array $templates Available templates. | ||
| * @param WP_Ultimo\Models\Customer $customer The customer object. | ||
| * @return array Modified templates array. | ||
| */ | ||
| $templates = apply_filters('wu_available_templates', $templates, $customer); | ||
|
|
||
| // Usage Example: | ||
| add_filter('wu_available_templates', function($templates, $customer) { | ||
| // Premium templates for VIP customers only | ||
| if (!$customer->is_vip()) { | ||
| foreach ($templates as $key => $template) { | ||
| if ($template['category'] === 'premium') { | ||
| unset($templates[$key]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $templates; | ||
| }, 10, 2); | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Integration Guide | ||
|
|
||
| ### Third-Party Service Integration | ||
|
|
||
| #### CRM Integration Example | ||
| ```php | ||
| // Hook into customer creation | ||
| add_action('wu_customer_post_create', 'sync_customer_to_crm'); | ||
|
|
||
| function sync_customer_to_crm($customer) { | ||
| $crm_api = new Your_CRM_API(); | ||
|
|
||
| $crm_api->create_contact([ | ||
| 'email' => $customer->get_email(), | ||
| 'name' => $customer->get_display_name(), | ||
| 'signup_date' => $customer->get_date_registered(), | ||
| 'plan' => $customer->get_membership()->get_plan()->get_name() | ||
| ]); | ||
|
|
||
| // Store CRM ID for future reference | ||
| $customer->add_meta('crm_contact_id', $crm_api->get_last_contact_id()); | ||
| } | ||
| ``` | ||
|
|
||
| #### Analytics Integration | ||
| ```php | ||
| // Track key events | ||
| add_action('wu_checkout_completed', 'track_conversion', 10, 3); | ||
| add_action('wu_membership_status_to_cancelled', 'track_churn'); | ||
| add_action('wu_payment_failed', 'track_payment_failure'); | ||
|
|
||
| function track_conversion($payment, $customer, $membership) { | ||
| // Google Analytics 4 | ||
| gtag('event', 'purchase', [ | ||
| 'transaction_id' => $payment->get_id(), | ||
| 'value' => $payment->get_total(), | ||
| 'currency' => $payment->get_currency(), | ||
| 'items' => [ | ||
| [ | ||
| 'item_id' => $membership->get_plan()->get_id(), | ||
| 'item_name' => $membership->get_plan()->get_name(), | ||
| 'category' => 'subscription', | ||
| 'quantity' => 1, | ||
| 'price' => $payment->get_total() | ||
| ] | ||
| ] | ||
| ]); | ||
| } | ||
| ``` | ||
|
|
||
| ### Custom Gateway Development | ||
|
|
||
| #### Create Custom Gateway | ||
| ```php | ||
| class My_Custom_Gateway extends \WP_Ultimo\Gateways\Base_Gateway { | ||
|
|
||
| public $id = 'my_gateway'; | ||
|
|
||
| public function __construct() { | ||
| $this->title = 'My Payment Gateway'; | ||
| $this->description = 'Custom payment processing'; | ||
| $this->supports = ['one-time', 'recurring']; | ||
|
|
||
| parent::__construct(); | ||
| } | ||
|
|
||
| public function process_single_payment($payment, $cart, $order) { | ||
| // Process one-time payment | ||
| $result = $this->api_call('charge', [ | ||
| 'amount' => $payment->get_total(), | ||
| 'currency' => $payment->get_currency(), | ||
| 'customer' => $payment->get_customer_id() | ||
| ]); | ||
|
|
||
| if ($result->success) { | ||
| $payment->set_gateway_payment_id($result->transaction_id); | ||
| $payment->set_status('completed'); | ||
| return true; | ||
| } | ||
|
|
||
| return new WP_Error('payment_failed', $result->error_message); | ||
| } | ||
|
|
||
| public function process_signup($membership, $customer, $cart, $order) { | ||
| // Set up recurring subscription | ||
| $subscription = $this->api_call('subscription/create', [ | ||
| 'customer_id' => $customer->get_gateway_customer_id(), | ||
| 'plan_id' => $membership->get_plan()->get_gateway_plan_id(), | ||
| 'trial_days' => $membership->get_trial_days() | ||
| ]); | ||
|
|
||
| if ($subscription->success) { | ||
| $membership->set_gateway_subscription_id($subscription->id); | ||
| return true; | ||
| } | ||
|
|
||
| return new WP_Error('subscription_failed', $subscription->error); | ||
| } | ||
| } | ||
|
|
||
| // Register the gateway | ||
| add_filter('wu_payment_gateways', function($gateways) { | ||
| $gateways['my_gateway'] = 'My_Custom_Gateway'; | ||
| return $gateways; | ||
| }); | ||
| ``` | ||
|
|
||
| ### Webhook Handling | ||
|
|
||
| #### Custom Webhook Endpoint | ||
| ```php | ||
| // Register webhook endpoint | ||
| add_action('rest_api_init', function() { | ||
| register_rest_route('my-addon/v1', '/webhook', [ | ||
| 'methods' => 'POST', | ||
| 'callback' => 'handle_my_webhook', | ||
| 'permission_callback' => 'verify_webhook_signature' | ||
| ]); | ||
| }); | ||
|
|
||
| function handle_my_webhook($request) { | ||
| $payload = $request->get_json_params(); | ||
|
|
||
| switch ($payload['event_type']) { | ||
| case 'customer.updated': | ||
| $customer = wu_get_customer($payload['customer_id']); | ||
| if ($customer) { | ||
| // Sync changes from external system | ||
| $customer->set_vip($payload['data']['is_vip']); | ||
| $customer->save(); | ||
| } | ||
| break; | ||
|
|
||
| case 'subscription.cancelled': | ||
| $membership = wu_get_membership_by_hash($payload['subscription_id']); | ||
| if ($membership) { | ||
| $membership->cancel(); | ||
| } | ||
| break; | ||
| } | ||
|
|
||
| return ['status' => 'processed']; | ||
| } | ||
|
|
||
| function verify_webhook_signature($request) { | ||
| $signature = $request->get_header('X-Webhook-Signature'); | ||
| $payload = $request->get_body(); | ||
| $secret = get_option('my_webhook_secret'); | ||
|
|
||
| return hash_hmac('sha256', $payload, $secret) === $signature; | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Addon Development | ||
|
|
||
| ### Addon Structure | ||
|
|
||
| ``` | ||
| my-addon/ | ||
| ├── my-addon.php # Main plugin file | ||
| ├── inc/ | ||
| │ ├── class-my-addon.php # Main addon class | ||
| │ ├── admin-pages/ # Admin interface | ||
| │ ├── models/ # Custom data models | ||
| │ └── integrations/ # Third-party integrations | ||
| ├── assets/ | ||
| │ ├── js/ | ||
| │ └── css/ | ||
| └── templates/ # Template files | ||
| ``` | ||
|
|
||
| ### Main Addon File Template | ||
|
|
||
| ```php | ||
| <?php | ||
| /** | ||
| * Plugin Name: My WP Ultimo Addon | ||
| * Description: Custom addon for WP Ultimo | ||
| * Version: 1.0.0 | ||
| * Author: Your Name | ||
| * Requires PHP: 7.4 | ||
| * WP Ultimo: 2.0.0 | ||
| */ | ||
|
|
||
| namespace My_Addon; | ||
|
|
||
| // Exit if accessed directly | ||
| defined('ABSPATH') || exit; | ||
|
|
||
| // Define constants | ||
| define('MY_ADDON_VERSION', '1.0.0'); | ||
| define('MY_ADDON_PLUGIN_FILE', __FILE__); | ||
| define('MY_ADDON_PATH', plugin_dir_path(__FILE__)); | ||
| define('MY_ADDON_URL', plugin_dir_url(__FILE__)); | ||
|
|
||
| // Check if WP Ultimo is active | ||
| add_action('plugins_loaded', function() { | ||
| if (!class_exists('WP_Ultimo\WP_Ultimo')) { | ||
| add_action('admin_notices', function() { | ||
| echo '<div class="notice notice-error"><p>'; | ||
| echo 'My Addon requires WP Ultimo to be installed and activated.'; | ||
| echo '</p></div>'; | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Initialize addon | ||
| My_Addon::get_instance(); | ||
| }); | ||
|
|
||
| /** | ||
| * Main addon class | ||
| */ | ||
| class My_Addon { | ||
|
|
||
| use \WP_Ultimo\Traits\Singleton; | ||
|
|
||
| /** | ||
| * Initialize the addon | ||
| */ | ||
| public function init() { | ||
| // Load dependencies | ||
| $this->load_dependencies(); | ||
|
|
||
| // Setup hooks | ||
| $this->setup_hooks(); | ||
|
|
||
| // Initialize components | ||
| $this->init_components(); | ||
| } | ||
|
|
||
| /** | ||
| * Load required files | ||
| */ | ||
| private function load_dependencies() { | ||
| require_once MY_ADDON_PATH . 'inc/class-my-addon.php'; | ||
| } | ||
|
|
||
| /** | ||
| * Setup WordPress hooks | ||
| */ | ||
| private function setup_hooks() { | ||
| // Activation/deactivation | ||
| register_activation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'activate']); | ||
| register_deactivation_hook(MY_ADDON_PLUGIN_FILE, [$this, 'deactivate']); | ||
|
|
||
| // WP Ultimo hooks | ||
| add_action('wu_checkout_completed', [$this, 'on_checkout_completed'], 10, 3); | ||
| add_filter('wu_checkout_form_fields', [$this, 'add_custom_fields'], 10, 2); | ||
| } | ||
|
|
||
| /** | ||
| * Initialize addon components | ||
| */ | ||
| private function init_components() { | ||
| // Initialize admin pages, models, etc. | ||
| } | ||
|
|
||
| /** | ||
| * Plugin activation | ||
| */ | ||
| public function activate() { | ||
| // Create custom tables, set options, etc. | ||
| $this->create_custom_table(); | ||
| update_option('my_addon_version', MY_ADDON_VERSION); | ||
| } | ||
|
|
||
| /** | ||
| * Plugin deactivation | ||
| */ | ||
| public function deactivate() { | ||
| // Cleanup if needed | ||
| } | ||
|
|
||
| /** | ||
| * Handle checkout completion | ||
| */ | ||
| public function on_checkout_completed($payment, $customer, $membership) { | ||
| // Custom logic when checkout completes | ||
| $this->send_welcome_email($customer); | ||
| $this->setup_customer_account($customer, $membership); | ||
| } | ||
|
|
||
| /** | ||
| * Add custom checkout fields | ||
| */ | ||
| public function add_custom_fields($fields, $form) { | ||
| $fields['company_size'] = [ | ||
| 'type' => 'select', | ||
| 'title' => 'Company Size', | ||
| 'options' => [ | ||
| 'small' => '1-10 employees', | ||
| 'medium' => '11-100 employees', | ||
| 'large' => '100+ employees' | ||
| ], | ||
| 'required' => false | ||
| ]; | ||
|
|
||
| return $fields; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Custom Model Example | ||
|
|
||
| ```php | ||
| <?php | ||
|
|
||
| namespace My_Addon\Models; | ||
|
|
||
| /** | ||
| * Custom Lead model | ||
| */ | ||
| class Lead extends \WP_Ultimo\Models\Base_Model { | ||
|
|
||
| /** | ||
| * Model name | ||
| */ | ||
| protected $model = 'lead'; | ||
|
|
||
| /** | ||
| * Set the database table | ||
| */ | ||
| protected function set_table() { | ||
| global $wpdb; | ||
| $this->table_name = "{$wpdb->prefix}my_addon_leads"; | ||
| } | ||
|
|
||
| /** | ||
| * Get the company name | ||
| */ | ||
| public function get_company() { | ||
| return $this->get_meta('company'); | ||
| } | ||
|
|
||
| /** | ||
| * Set the company name | ||
| */ | ||
| public function set_company($company) { | ||
| return $this->add_meta('company', $company); | ||
| } | ||
|
|
||
| /** | ||
| * Convert lead to customer | ||
| */ | ||
| public function convert_to_customer($user_data = []) { | ||
| // Create WordPress user | ||
| $user_id = wp_create_user( | ||
| $user_data['username'] ?? $this->get_email(), | ||
| $user_data['password'] ?? wp_generate_password(), | ||
| $this->get_email() | ||
| ); | ||
|
|
||
| if (is_wp_error($user_id)) { | ||
| return $user_id; | ||
| } | ||
|
|
||
| // Create WP Ultimo customer | ||
| $customer = wu_create_customer([ | ||
| 'user_id' => $user_id, | ||
| 'email_verification' => 'verified', | ||
| 'type' => 'customer' | ||
| ]); | ||
|
|
||
| if (is_wp_error($customer)) { | ||
| return $customer; | ||
| } | ||
|
|
||
| // Copy lead data to customer | ||
| $customer->add_meta('company', $this->get_company()); | ||
| $customer->add_meta('lead_source', $this->get_source()); | ||
|
|
||
| // Mark lead as converted | ||
| $this->set_status('converted'); | ||
| $this->add_meta('converted_customer_id', $customer->get_id()); | ||
| $this->save(); | ||
|
|
||
| return $customer; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Admin Page Integration | ||
|
|
||
| ```php | ||
| <?php | ||
|
|
||
| namespace My_Addon\Admin_Pages; | ||
|
|
||
| /** | ||
| * Custom admin page | ||
| */ | ||
| class Leads_Admin_Page extends \WP_Ultimo\Admin_Pages\Base_Admin_Page { | ||
|
|
||
| /** | ||
| * Page ID | ||
| */ | ||
| protected $id = 'my-addon-leads'; | ||
|
|
||
| /** | ||
| * Menu position | ||
| */ | ||
| protected $position = 30; | ||
|
|
||
| /** | ||
| * Initialize page | ||
| */ | ||
| public function init() { | ||
| // Register with WP Ultimo | ||
| add_action('wu_register_admin_pages', [$this, 'register']); | ||
| } | ||
|
|
||
| /** | ||
| * Register the admin page | ||
| */ | ||
| public function register() { | ||
| wu_register_admin_page($this->id, [ | ||
| 'title' => __('Leads', 'my-addon'), | ||
| 'menu_title' => __('Leads', 'my-addon'), | ||
| 'capability' => 'wu_read_leads', | ||
| 'position' => $this->position, | ||
| 'parent' => 'wp-ultimo', | ||
| 'callback' => [$this, 'render'] | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * Render the page | ||
| */ | ||
| public function render() { | ||
| // Get leads data | ||
| $leads = My_Addon\Models\Lead::query([ | ||
| 'number' => 20, | ||
| 'paged' => absint($_GET['paged'] ?? 1) | ||
| ]); | ||
|
|
||
| // Render template | ||
| wu_get_template('admin/leads-list', [ | ||
| 'leads' => $leads, | ||
| 'page_title' => __('Manage Leads', 'my-addon') | ||
| ]); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## Code Examples | ||
|
|
||
| ### Advanced Integration Examples | ||
|
|
||
| #### 1. Multi-Gateway Payment Processing | ||
|
|
||
| ```php | ||
| /** | ||
| * Process payment with fallback gateways | ||
| */ | ||
| class Smart_Payment_Processor { | ||
|
|
||
| private $gateway_priority = ['stripe', 'paypal', 'manual']; | ||
|
|
||
| public function process_payment_with_fallback($payment, $cart) { | ||
| foreach ($this->gateway_priority as $gateway_id) { | ||
| $gateway = wu_get_gateway($gateway_id); | ||
|
|
||
| if (!$gateway || !$gateway->is_available()) { | ||
| continue; | ||
| } | ||
|
|
||
| $result = $gateway->process_single_payment($payment, $cart); | ||
|
|
||
| if (!is_wp_error($result)) { | ||
| // Payment successful | ||
| do_action('wu_payment_processed_successfully', $payment, $gateway_id); | ||
| return $result; | ||
| } | ||
|
|
||
| // Log failed attempt | ||
| wu_log_add('payment-processing', sprintf( | ||
| 'Gateway %s failed for payment %d: %s', | ||
| $gateway_id, | ||
| $payment->get_id(), | ||
| $result->get_error_message() | ||
| )); | ||
| } | ||
|
|
||
| // All gateways failed | ||
| do_action('wu_payment_processing_failed', $payment); | ||
| return new WP_Error('all_gateways_failed', 'All payment methods failed'); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| #### 2. Dynamic Pricing Engine | ||
|
|
||
| ```php | ||
| /** | ||
| * Advanced pricing rules engine | ||
| */ | ||
| class Dynamic_Pricing_Engine { | ||
|
|
||
| public function __construct() { | ||
| add_filter('wu_cart_total', [$this, 'apply_dynamic_pricing'], 20, 2); | ||
| add_filter('wu_product_price', [$this, 'modify_product_price'], 10, 3); | ||
| } | ||
|
|
||
| public function apply_dynamic_pricing($total, $cart) { | ||
| $customer = $cart->get_customer(); | ||
| $rules = $this->get_pricing_rules(); | ||
|
|
||
| foreach ($rules as $rule) { | ||
| if ($this->rule_applies($rule, $cart, $customer)) { | ||
| $total = $this->apply_rule($rule, $total, $cart); | ||
| } | ||
| } | ||
|
|
||
| return $total; | ||
| } | ||
|
|
||
| private function get_pricing_rules() { | ||
| return [ | ||
| [ | ||
| 'type' => 'volume_discount', | ||
| 'condition' => ['total_greater_than' => 100], | ||
| 'discount' => 0.1 // 10% | ||
| ], | ||
| [ | ||
| 'type' => 'loyalty_discount', | ||
| 'condition' => ['customer_tenure_months' => 12], | ||
| 'discount' => 0.15 // 15% | ||
| ], | ||
| [ | ||
| 'type' => 'seasonal_promo', | ||
| 'condition' => ['date_range' => ['2024-11-01', '2024-12-31']], | ||
| 'discount' => 0.2 // 20% | ||
| ] | ||
| ]; | ||
| } | ||
|
|
||
| private function rule_applies($rule, $cart, $customer) { | ||
| foreach ($rule['condition'] as $condition => $value) { | ||
| switch ($condition) { | ||
| case 'total_greater_than': | ||
| if ($cart->get_total() <= $value) return false; | ||
| break; | ||
|
|
||
| case 'customer_tenure_months': | ||
| if (!$customer || $customer->get_months_active() < $value) return false; | ||
| break; | ||
|
|
||
| case 'date_range': | ||
| $now = current_time('Y-m-d'); | ||
| if ($now < $value[0] || $now > $value[1]) return false; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private function apply_rule($rule, $total, $cart) { | ||
| $discount_amount = $total * $rule['discount']; | ||
|
|
||
| // Log the discount application | ||
| wu_log_add('pricing', sprintf( | ||
| 'Applied %s rule: %.2f discount on total %.2f', | ||
| $rule['type'], | ||
| $discount_amount, | ||
| $total | ||
| )); | ||
|
|
||
| return $total - $discount_amount; | ||
| } | ||
| } | ||
|
|
||
| new Dynamic_Pricing_Engine(); | ||
| ``` | ||
|
|
||
| #### 3. Advanced Site Provisioning | ||
|
|
||
| ```php | ||
| /** | ||
| * Custom site provisioning with external services | ||
| */ | ||
| class Advanced_Site_Provisioner { | ||
|
|
||
| public function __construct() { | ||
| add_action('wu_site_published', [$this, 'provision_site'], 10, 2); | ||
| add_action('wu_membership_status_to_expired', [$this, 'suspend_site_services']); | ||
| } | ||
|
|
||
| public function provision_site($site, $membership) { | ||
| $plan = $membership->get_plan(); | ||
|
|
||
| // Configure based on plan features | ||
| switch_to_blog($site->get_id()); | ||
|
|
||
| // Install plugins based on plan | ||
| $this->install_plan_plugins($plan); | ||
|
|
||
| // Configure SSL | ||
| if ($plan->has_feature('ssl')) { | ||
| $this->setup_ssl($site); | ||
| } | ||
|
|
||
| // Setup CDN | ||
| if ($plan->has_feature('cdn')) { | ||
| $this->configure_cdn($site); | ||
| } | ||
|
|
||
| // Configure backups | ||
| if ($plan->has_feature('backups')) { | ||
| $this->setup_automated_backups($site, $plan->get_backup_frequency()); | ||
| } | ||
|
|
||
| // Setup monitoring | ||
| $this->setup_site_monitoring($site, $membership->get_customer()); | ||
|
|
||
| restore_current_blog(); | ||
|
|
||
| // Send completion notification | ||
| $this->send_provisioning_complete_email($site, $membership); | ||
| } | ||
|
|
||
| private function install_plan_plugins($plan) { | ||
| $plugins = $plan->get_included_plugins(); | ||
|
|
||
| foreach ($plugins as $plugin_slug) { | ||
| if ($this->plugin_exists($plugin_slug)) { | ||
| activate_plugin($plugin_slug); | ||
|
|
||
| // Configure plugin if needed | ||
| $this->configure_plugin($plugin_slug, $plan); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private function setup_ssl($site) { | ||
| $domain = $site->get_domain(); | ||
|
|
||
| // API call to SSL provider | ||
| $ssl_service = new SSL_Provider_API(); | ||
| $result = $ssl_service->request_certificate($domain); | ||
|
|
||
| if ($result->success) { | ||
| $site->add_meta('ssl_certificate_id', $result->certificate_id); | ||
| $site->add_meta('ssl_status', 'active'); | ||
| } | ||
| } | ||
|
|
||
| private function configure_cdn($site) { | ||
| $cdn_service = new CDN_Provider_API(); | ||
|
|
||
| $zone = $cdn_service->create_zone([ | ||
| 'name' => $site->get_domain(), | ||
| 'type' => 'full' | ||
| ]); | ||
|
|
||
| if ($zone->success) { | ||
| $site->add_meta('cdn_zone_id', $zone->id); | ||
|
|
||
| // Update DNS records | ||
| $this->update_cdn_dns($site, $zone); | ||
| } | ||
| } | ||
|
|
||
| private function setup_automated_backups($site, $frequency) { | ||
| $backup_service = new Backup_Provider_API(); | ||
|
|
||
| $schedule = $backup_service->create_schedule([ | ||
| 'site_id' => $site->get_id(), | ||
| 'frequency' => $frequency, | ||
| 'retention' => 30 // days | ||
| ]); | ||
|
|
||
| $site->add_meta('backup_schedule_id', $schedule->id); | ||
| } | ||
|
|
||
| private function setup_site_monitoring($site, $customer) { | ||
| $monitoring_service = new Monitoring_API(); | ||
|
|
||
| $monitor = $monitoring_service->create_monitor([ | ||
| 'url' => $site->get_domain(), | ||
| 'customer_email' => $customer->get_email(), | ||
| 'check_interval' => 300 // 5 minutes | ||
| ]); | ||
|
|
||
| $site->add_meta('monitoring_id', $monitor->id); | ||
| } | ||
| } | ||
|
|
||
| new Advanced_Site_Provisioner(); | ||
| ``` | ||
|
|
||
| #### 4. Custom Limitations System | ||
|
|
||
| ```php | ||
| /** | ||
| * Advanced limitations with usage tracking | ||
| */ | ||
| class Advanced_Limitations { | ||
|
|
||
| public function __construct() { | ||
| add_filter('wu_limitation_plugins_allowed', [$this, 'check_plugin_limit'], 10, 3); | ||
| add_filter('wu_limitation_storage_allowed', [$this, 'check_storage_limit'], 10, 3); | ||
| add_action('activated_plugin', [$this, 'track_plugin_activation'], 10, 2); | ||
| } | ||
|
|
||
| public function check_plugin_limit($allowed, $site_id, $membership) { | ||
| $plan = $membership->get_plan(); | ||
| $max_plugins = $plan->get_limit('max_plugins', 10); | ||
|
|
||
| // Count active plugins | ||
| switch_to_blog($site_id); | ||
| $active_plugins = count(get_option('active_plugins', [])); | ||
| restore_current_blog(); | ||
|
|
||
| if ($active_plugins >= $max_plugins) { | ||
| // Send warning notification | ||
| $this->send_limit_warning($membership->get_customer(), 'plugins', $max_plugins); | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| public function check_storage_limit($allowed, $site_id, $membership) { | ||
| $plan = $membership->get_plan(); | ||
| $max_storage = $plan->get_limit('max_storage_mb', 1000); // MB | ||
|
|
||
| $current_usage = $this->get_site_storage_usage($site_id); | ||
|
|
||
| if ($current_usage >= $max_storage) { | ||
| // Log limit reached | ||
| wu_log_add('limitations', sprintf( | ||
| 'Site %d reached storage limit: %dMB/%dMB', | ||
| $site_id, | ||
| $current_usage, | ||
| $max_storage | ||
| )); | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| // Warn at 80% usage | ||
| if ($current_usage >= ($max_storage * 0.8)) { | ||
| $this->send_storage_warning($membership->get_customer(), $current_usage, $max_storage); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| private function get_site_storage_usage($site_id) { | ||
| // Calculate actual storage usage | ||
| $upload_dir = wp_upload_dir(); | ||
| $size = $this->get_directory_size($upload_dir['basedir']); | ||
|
|
||
| // Convert to MB | ||
| return round($size / 1024 / 1024, 2); | ||
| } | ||
|
|
||
| private function get_directory_size($directory) { | ||
| $size = 0; | ||
| $files = new RecursiveIteratorIterator( | ||
| new RecursiveDirectoryIterator($directory) | ||
| ); | ||
|
|
||
| foreach ($files as $file) { | ||
| if ($file->isFile()) { | ||
| $size += $file->getSize(); | ||
| } | ||
| } | ||
|
|
||
| return $size; | ||
| } | ||
|
|
||
| private function send_limit_warning($customer, $limit_type, $limit_value) { | ||
| wu_mail_customer($customer, 'limit_warning', [ | ||
| 'limit_type' => $limit_type, | ||
| 'limit_value' => $limit_value, | ||
| 'upgrade_url' => wu_get_current_url('upgrade') | ||
| ]); | ||
| } | ||
| } | ||
|
|
||
| new Advanced_Limitations(); | ||
| ``` | ||
|
|
||
| ### Testing Your Integration | ||
|
|
||
| #### Unit Test Example | ||
|
|
||
| ```php | ||
| <?php | ||
|
|
||
| class Test_My_Integration extends WP_UnitTestCase { | ||
|
|
||
| public function setUp() { | ||
| parent::setUp(); | ||
|
|
||
| // Create test customer | ||
| $this->customer = wu_create_customer([ | ||
| 'user_id' => $this->factory->user->create(), | ||
| 'type' => 'customer' | ||
| ]); | ||
|
|
||
| // Create test membership | ||
| $this->membership = wu_create_membership([ | ||
| 'customer_id' => $this->customer->get_id(), | ||
| 'plan_id' => $this->create_test_plan() | ||
| ]); | ||
| } | ||
|
|
||
| public function test_custom_field_saves_correctly() { | ||
| $checkout = new WP_Ultimo\Checkout\Checkout(); | ||
|
|
||
| // Simulate form submission | ||
| $_POST['company_size'] = 'medium'; | ||
|
|
||
| $result = $checkout->process_step_data([ | ||
| 'company_size' => 'medium' | ||
| ]); | ||
|
|
||
| $this->assertTrue($result); | ||
|
|
||
| // Verify data was saved | ||
| $saved_value = $this->customer->get_meta('company_size'); | ||
| $this->assertEquals('medium', $saved_value); | ||
| } | ||
|
|
||
| public function test_pricing_rule_applies() { | ||
| $engine = new Dynamic_Pricing_Engine(); | ||
|
|
||
| $cart = new WP_Ultimo\Checkout\Cart([ | ||
| 'customer_id' => $this->customer->get_id(), | ||
| 'products' => ['test-plan'] | ||
| ]); | ||
|
|
||
| $cart->set_total(150); // Above $100 threshold | ||
|
|
||
| $new_total = $engine->apply_dynamic_pricing(150, $cart); | ||
|
|
||
| // Should have 10% discount | ||
| $this->assertEquals(135, $new_total); | ||
| } | ||
|
|
||
| private function create_test_plan() { | ||
| return wu_create_product([ | ||
| 'name' => 'Test Plan', | ||
| 'type' => 'plan', | ||
| 'price' => 50, | ||
| 'duration' => 1, | ||
| 'duration_unit' => 'month' | ||
| ])->get_id(); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| This comprehensive documentation provides developers with all the tools and knowledge needed to integrate with, extend, and build upon WP Ultimo's powerful platform. The extensive API, hook system, and examples enable creation of sophisticated SaaS solutions and custom integrations. | ||
| See [Official Ultimate Multisite Documentation Site](https://ultimatemultisite.com/docs/developer) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for other documentation files in the repository
echo "=== Documentation Files ==="
fd -e md -e txt -e rst | grep -iE '(doc|readme|guide|api|developer)' | head -20
echo ""
echo "=== Searching for Settings API references ==="
rg -i "settings.*api|api.*settings" --type php -A 2 | head -30
echo ""
echo "=== Checking for REST API or endpoint documentation ==="
fd -e md -e txt | xargs grep -l -i "rest.*api\|endpoint\|settings" 2>/dev/null | head -10Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2417
🌐 Web query:
Ultimate Multisite documentation Settings REST API endpoints
💡 Result:
For Ultimate Multisite (a WordPress Multisite plugin), “Settings” REST API endpoints generally refer to WordPress core’s Site Settings endpoint (per-site), not a special Ultimate Multisite-only settings endpoint.
Site Settings (WordPress core)
- Get site settings:
GET /wp-json/wp/v2/settings[1] - Update site settings:
POST /wp-json/wp/v2/settings[1] - Discover schema / supported methods:
OPTIONS /wp-json/wp/v2/settings[1]
Multisite note (important)
- The REST API is site-specific in multisite: call the endpoint on the individual site’s URL (e.g.,
https://example.com/site1/wp-json/wp/v2/settings). WordPress does not provide “network admin” multisite endpoints by default. [2]
How to confirm what your install exposes
- List all available routes on that site:
GET /wp-json/[3]
Sources:
[1] WordPress REST API Handbook – Site Settings (/wp/v2/settings) (developer.wordpress.org)
[2] WordPress StackExchange – REST API with multisite (site-specific; no network endpoints by default) (wordpress.stackexchange.com)
[3] WordPress REST API Handbook – Routes and Endpoints (API index at /wp-json/) (developer.wordpress.org)
Citations:
- 1: https://developer.wordpress.org/rest-api/reference/settings/?utm_source=openai
- 2: https://wordpress.stackexchange.com/questions/304024/does-the-wordpress-api-work-with-multisite?utm_source=openai
- 3: https://developer.wordpress.org/rest-api/extending-the-rest-api/routes-and-endpoints/?utm_source=openai
Removing dedicated in-repository developer documentation weakens maintainability.
While the repository retains other documentation files (README.md, deprecated-apis.md, E2E-TESTING-GUIDE.md), removing the dedicated DEVELOPER-DOCUMENTATION.md and replacing it with only an external link creates concerns:
- No offline access to developer guides – Contributors cannot access documentation without internet connectivity.
- Lost version control integration – Developer documentation is no longer tracked with code changes, making it harder to keep docs in sync with specific versions.
- Reduced contributor context – Reviewers lack immediate in-repository reference material when examining changes.
- External dependency risk – The external link may become stale, moved, or out of sync with the codebase.
Consider keeping essential developer documentation in-repository alongside the external link, or consolidating developer content into README.md or a dedicated section if the external site is the source of truth.
🤖 Prompt for AI Agents
In `@DEVELOPER-DOCUMENTATION.md` around lines 1 - 3, The
DEVELOPER-DOCUMENTATION.md was removed and replaced with only an external link,
which reduces offline availability and versioned context; restore essential
in-repo developer guidance by either (a) re-adding core sections back into
DEVELOPER-DOCUMENTATION.md (setup, architecture overview, contribution
guidelines, key workflows) and keep the external link as “canonical” reference,
or (b) consolidate those essential sections into README.md under a “Developer
Documentation” header and include the external link and a note that it is the
full canonical source; ensure the file(s) reference the same headings and
examples as the external site so reviewers have immediate, versioned docs
in-repo.
| #: inc/class-settings.php:715 | ||
| #: views/settings/widget-settings-body.php:278 | ||
| #: views/settings/widget-settings-body.php:283 | ||
| msgid "Help Improve Ultimate Multisite" | ||
| msgstr "" | ||
|
|
||
| #. translators: %s is a link to the privacy policy | ||
| #: inc/class-settings.php:718 | ||
| #, php-format | ||
| msgid "Allow Ultimate Multisite to collect anonymous usage data and error reports to help us improve the plugin. We collect: PHP version, WordPress version, plugin version, network type (subdomain/subdirectory), aggregate counts (sites, memberships), active gateways, and error logs. We never collect personal data, customer information, or domain names. <a href=\"%s\" target=\"_blank\">Learn more</a>." | ||
| msgstr "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Search for tracker/telemetry related files and code
find . -type f -name "*track*" | head -20
rg -l "tracker|telemetry|usage.*data|error.*report" --type php inc/ | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 310
🏁 Script executed:
#!/bin/bash
# Search for data collection, payload, and specific tracked fields
rg -n -A 5 "payload|collect.*data|send.*data" inc/class-settings.php inc/class-tracker.php 2>/dev/null | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4692
🏁 Script executed:
#!/bin/bash
# Look for the actual tracker class or usage tracking implementation
rg -n "class.*[Tt]racker|function.*track|wp_remote_post.*track|wp_remote_get.*track" inc/ -A 3Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1295
🏁 Script executed:
#!/bin/bash
# Search for privacy policy references and data collection disclosures in code
rg -n "privacy|personal.*data|domain.*name|collect" inc/class-settings.php -B 2 -A 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 784
🏁 Script executed:
#!/bin/bash
# Get the full get_tracking_data() function implementation
sed -n '183,350p' inc/class-tracker.php | head -200Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4438
🏁 Script executed:
#!/bin/bash
# Check what specific fields are being collected
rg -n "php.*version|wp_version|version|network.*type|aggregate|gateways|sites|memberships" inc/class-tracker.php -A 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2968
🏁 Script executed:
#!/bin/bash
# Check the error reporting/logging collection
rg -n "get_error|error.*data|log.*data" inc/class-tracker.php -A 5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2860
🏁 Script executed:
#!/bin/bash
# Look at send_to_api to see complete payload structure
sed -n '922,960p' inc/class-tracker.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 506
Update telemetry disclosure to match actual data collection scope.
The implementation collects significantly more data than stated in the disclosure. Beyond the disclosed fields (PHP/WordPress/plugin version, network type, aggregate site/membership counts, active gateways, error logs), the code also collects: MySQL version, server software type, timezone, locale, SSL status, active add-ons, customer/product/payment/domain counts, memory limits, and error file paths/stack traces.
The disclosure must be updated to accurately reflect all collected fields, or data collection must be restricted to match the disclosure. Additionally, error reporting should clarify what constitutes "error logs" (currently includes file paths and sanitized stack traces).
🤖 Prompt for AI Agents
In `@lang/ultimate-multisite.pot` around lines 8917 - 8927, The telemetry
disclosure string (msgid "Allow Ultimate Multisite to collect anonymous usage
data..." used in inc/class-settings.php and
views/settings/widget-settings-body.php) is incomplete; either update that
message to enumerate all actually collected fields (MySQL version, server
software, timezone, locale, SSL status, active add-ons,
customer/product/payment/domain counts, memory limits, error file paths and
sanitized stack traces, etc.) and clarify what "error logs" contains, or change
the collection logic in the code paths that assemble telemetry to remove any
fields not currently disclosed; adjust the text in the class-settings.php/views
strings to precisely match the chosen scope and ensure the error-reporting
wording explicitly states inclusion of file paths and sanitized stack traces.
- Add should_collect_payment() method to Checkout class to determine payment requirements earlier in the flow - Only validate billing address when payment is actually being collected - Apply WordPress code style fixes to hooks documentation generator Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
🤖 Fix all issues with AI agents
In `@inc/checkout/class-checkout.php`:
- Around line 1958-1991: The temporary Cart created in should_collect_payment()
only passed 'products' and 'country', which can miscompute payment needs because
Cart->should_collect_payment() depends on pricing-related inputs; update the
Cart construction to pass the same pricing/session fields used elsewhere in
checkout/cart (pull via $this->request_or_session) — include discounts/coupons,
duration/interval, trial parameters, PWYW amount, recurring/price overrides and
any other pricing-related keys your checkout normally sends — so that new
Cart(...) mirrors the real checkout/cart data before calling
should_collect_payment() on it.
In `@inc/documentation/generate-berlindb-hooks.php`:
- Around line 351-545: The script currently calls
file_put_contents($output_file, $content) but always prints a success message;
modify the block around file_put_contents so you capture its return value (e.g.
$bytes = file_put_contents($output_file, $content)), check for === false, and on
failure write a clear error to STDERR (including $output_file) and exit with a
non-zero status (exit(1)); only print the "Written to ..." message to STDERR
when the write succeeded. Use the existing symbols $output_file, $content,
fwrite and STDERR to implement the check and error path.
- Around line 52-79: The find_line_number function should guard against
unreadable files and cache files per filename: change the single static $lines
to a static associative cache (e.g. $cache) keyed by $file, return 0 immediately
if !is_readable($file) before calling file(), and load file contents into
$cache[$file] = file($file) when first accessed; keep the loop and the rest of
the logic intact so existing callers (e.g. the $hook_lines pre-computation)
continue to work.
- Around line 141-194: The greedy regex in extract_schema_columns that finds the
$columns block ('/\$columns\s*=\s*\[(.+)\];/s') can over-capture; change it to a
non-greedy match so it stops at the first closing bracket by using
'/\$columns\s*=\s*\[(.+?)\];/s' (keep the s flag), leaving the rest of the
function unchanged so $m[1] still contains the intended block for parsing.
In `@lang/ultimate-multisite.pot`:
- Around line 4193-4199: Update the source string in
class-product-edit-admin-page.php that currently reads "This products will be
offered inside upgrade/downgrade forms as order bumps." to the correct plural
"These products will be offered inside upgrade/downgrade forms as order bumps."
(refer to the msgid "This products will be offered inside upgrade/downgrade
forms as order bumps." in the POT diff), then regenerate the POT so the
corrected string replaces the old msgid in lang/ultimate-multisite.pot.
- Around line 7043-7046: Update the source string in the checkout code that
generates the discount-code error: replace "The code %s do not exist or is no
longer valid." with the grammatically correct "The code %s does not exist or is
no longer valid." (the string originates from the message in
inc/checkout/class-cart.php referenced by the msgid in
lang/ultimate-multisite.pot); after changing the source string, regenerate the
POT so the updated msgid is propagated to translations.
- Around line 9019-9024: Remove the trailing space from the "Force Synchronous
Site Publication " label in the source (inc/class-settings.php where that msgid
is defined, e.g., in the Settings class or the method that builds the label) so
it becomes "Force Synchronous Site Publication" exactly, then regenerate the
POT/translation files so the corrected msgid replaces the one with the trailing
space; ensure the change is made at the original msgid location referenced in
inc/class-settings.php to avoid breaking exact msgid matching.
| function extract_schema_columns(string $file): array { | ||
|
|
||
| $source = file_get_contents($file); | ||
|
|
||
| // Extract the $columns array block. | ||
| if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) { | ||
| return []; | ||
| } | ||
|
|
||
| $columns_block = $m[1]; | ||
| $all_columns = []; | ||
| $transition_columns = []; | ||
| $seen_names = []; | ||
|
|
||
| // Split into individual column arrays. | ||
| // Each column is delimited by [ ... ], | ||
| preg_match_all('/\[\s*(.*?)\s*\]/s', $columns_block, $entries); | ||
|
|
||
| foreach ($entries[1] as $entry) { | ||
| $col = []; | ||
|
|
||
| // Extract name | ||
| if (preg_match("/'name'\s*=>\s*'([^']+)'/", $entry, $nm)) { | ||
| $col['name'] = $nm[1]; | ||
| } else { | ||
| continue; | ||
| } | ||
|
|
||
| // Extract type | ||
| if (preg_match("/'type'\s*=>\s*'([^']+)'/", $entry, $tm)) { | ||
| $col['type'] = $tm[1]; | ||
| } else { | ||
| $col['type'] = 'mixed'; | ||
| } | ||
|
|
||
| // Deduplicate columns (some schemas define the same column twice). | ||
| if (isset($seen_names[ $col['name'] ])) { | ||
| continue; | ||
| } | ||
|
|
||
| $seen_names[ $col['name'] ] = true; | ||
| $all_columns[] = $col; | ||
|
|
||
| // Check for transition => true | ||
| if (preg_match("/'transition'\s*=>\s*true/", $entry)) { | ||
| $transition_columns[] = $col; | ||
| } | ||
| } | ||
|
|
||
| return [ | ||
| 'all' => $all_columns, | ||
| 'transition' => $transition_columns, | ||
| ]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
wc -l inc/documentation/generate-berlindb-hooks.php
sed -n '1,200p' inc/documentation/generate-berlindb-hooks.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 5940
Use a non-greedy regex for the $columns block.
The current greedy match can over-capture if other arrays follow.
Patch
-if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) {
+if (! preg_match('/\$columns\s*=\s*\[(.+?)\];/s', $source, $m)) {
return [];
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function extract_schema_columns(string $file): array { | |
| $source = file_get_contents($file); | |
| // Extract the $columns array block. | |
| if (! preg_match('/\$columns\s*=\s*\[(.+)\];/s', $source, $m)) { | |
| return []; | |
| } | |
| $columns_block = $m[1]; | |
| $all_columns = []; | |
| $transition_columns = []; | |
| $seen_names = []; | |
| // Split into individual column arrays. | |
| // Each column is delimited by [ ... ], | |
| preg_match_all('/\[\s*(.*?)\s*\]/s', $columns_block, $entries); | |
| foreach ($entries[1] as $entry) { | |
| $col = []; | |
| // Extract name | |
| if (preg_match("/'name'\s*=>\s*'([^']+)'/", $entry, $nm)) { | |
| $col['name'] = $nm[1]; | |
| } else { | |
| continue; | |
| } | |
| // Extract type | |
| if (preg_match("/'type'\s*=>\s*'([^']+)'/", $entry, $tm)) { | |
| $col['type'] = $tm[1]; | |
| } else { | |
| $col['type'] = 'mixed'; | |
| } | |
| // Deduplicate columns (some schemas define the same column twice). | |
| if (isset($seen_names[ $col['name'] ])) { | |
| continue; | |
| } | |
| $seen_names[ $col['name'] ] = true; | |
| $all_columns[] = $col; | |
| // Check for transition => true | |
| if (preg_match("/'transition'\s*=>\s*true/", $entry)) { | |
| $transition_columns[] = $col; | |
| } | |
| } | |
| return [ | |
| 'all' => $all_columns, | |
| 'transition' => $transition_columns, | |
| ]; | |
| } | |
| function extract_schema_columns(string $file): array { | |
| $source = file_get_contents($file); | |
| // Extract the $columns array block. | |
| if (! preg_match('/\$columns\s*=\s*\[(.+?)\];/s', $source, $m)) { | |
| return []; | |
| } | |
| $columns_block = $m[1]; | |
| // …rest of function… | |
| } |
🤖 Prompt for AI Agents
In `@inc/documentation/generate-berlindb-hooks.php` around lines 141 - 194, The
greedy regex in extract_schema_columns that finds the $columns block
('/\$columns\s*=\s*\[(.+)\];/s') can over-capture; change it to a non-greedy
match so it stops at the first closing bracket by using
'/\$columns\s*=\s*\[(.+?)\];/s' (keep the s flag), leaving the rest of the
function unchanged so $m[1] still contains the intended block for parsing.
| $output_file = __DIR__ . '/berlindb-dynamic-hooks.php'; | ||
| $prefix = 'wu'; | ||
| $src = $query_php_relative; | ||
|
|
||
| ob_start(); | ||
|
|
||
| echo "<?php\n"; | ||
|
|
||
| // Build the @see line for transition hooks. | ||
| $transition_see = $hook_lines['transition'] ? "see {$src}:{$hook_lines['transition']}" : "see {$src}"; | ||
|
|
||
| echo <<<'HEADER' | ||
| /** | ||
| * BerlinDB Dynamic Hooks Reference | ||
| * | ||
| * AUTO-GENERATED — do not edit manually. | ||
| * Regenerate with: php inc/documentation/generate-berlindb-hooks.php | ||
| * | ||
| * This file exists solely so that @10up/wp-hooks-documentor can discover | ||
| * the dynamic hooks fired by BerlinDB's Query class for every registered | ||
| * Ultimate Multisite model. It is never loaded at runtime. | ||
| * | ||
| * @package WP_Ultimo | ||
| * @subpackage Documentation | ||
| * @since 2.3.0 | ||
| * @generated | ||
| */ | ||
|
|
||
| defined('ABSPATH') || exit; | ||
|
|
||
| // phpcs:disable -- This file is never executed; it only carries docblocks. | ||
|
|
||
| // Variable declarations to keep static analysers and editors happy. | ||
| $old_value = null; | ||
| $new_value = null; | ||
| $item_id = 0; | ||
| $query = null; | ||
| $clauses = []; | ||
| $search_columns = []; | ||
| $search = ''; | ||
| $items = []; | ||
| $item = []; | ||
| $sql = ''; | ||
|
|
||
| HEADER; | ||
|
|
||
| foreach ($models as $model) { | ||
| $item = $model['item_name']; | ||
| $items = $model['item_name_plural']; | ||
| $label = humanize($item); | ||
| $labels = humanize($items); | ||
| $section = ucwords($label); | ||
|
|
||
| echo "\n// ─── {$section} " . str_repeat('─', max(1, 72 - strlen($section))) . "\n"; | ||
|
|
||
| // ── Transition hooks ──────────────────────────────────────────────── | ||
| foreach ($model['transition_cols'] as $col) { | ||
| $col_name = $col['name']; | ||
| $phpdoc = mysql_type_to_phpdoc($col['type']); | ||
| $hook_name = "{$prefix}_transition_{$item}_{$col_name}"; | ||
| $col_human = str_replace('_', ' ', $col_name); | ||
| $see_line = $transition_see; | ||
|
|
||
| echo <<<HOOK | ||
|
|
||
| /** | ||
| * Fires when the {$col_human} of a {$label} transitions from one value to another. | ||
| * | ||
| * This hook is fired by BerlinDB when a database UPDATE changes the `{$col_name}` | ||
| * column for a {$label} row. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_line} | ||
| * | ||
| * @param {$phpdoc} \$old_value The previous {$col_human} value. | ||
| * @param {$phpdoc} \$new_value The new {$col_human} value. | ||
| * @param int \$item_id The {$label} ID. | ||
| */ | ||
| do_action('{$hook_name}', \$old_value, \$new_value, \$item_id); | ||
|
|
||
| HOOK; | ||
| } | ||
|
|
||
| // ── Query hooks ───────────────────────────────────────────────────── | ||
|
|
||
| $see_pre_get = $hook_lines['pre_get'] ? "see {$src}:{$hook_lines['pre_get']}" : "see {$src}"; | ||
| $see_parse_query = $hook_lines['parse_query'] ? "see {$src}:{$hook_lines['parse_query']}" : "see {$src}"; | ||
| $see_query_clauses = $hook_lines['query_clauses'] ? "see {$src}:{$hook_lines['query_clauses']}" : "see {$src}"; | ||
| $see_search_cols = $hook_lines['search_columns'] ? "see {$src}:{$hook_lines['search_columns']}" : "see {$src}"; | ||
| $see_the_items = $hook_lines['the_items'] ? "see {$src}:{$hook_lines['the_items']}" : "see {$src}"; | ||
| $see_filter_item = $hook_lines['filter_item'] ? "see {$src}:{$hook_lines['filter_item']}" : "see {$src}"; | ||
| $see_found_query = $hook_lines['found_query'] ? "see {$src}:{$hook_lines['found_query']}" : "see {$src}"; | ||
|
|
||
| echo <<<HOOK | ||
|
|
||
| /** | ||
| * Fires before {$labels} are fetched from the database. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_pre_get} | ||
| * | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| */ | ||
| do_action_ref_array('{$prefix}_pre_get_{$items}', [&\$query]); | ||
|
|
||
| /** | ||
| * Fires after {$labels} query vars have been parsed. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_parse_query} | ||
| * | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| */ | ||
| do_action_ref_array('{$prefix}_parse_{$items}_query', [&\$query]); | ||
|
|
||
| /** | ||
| * Filters the SQL clauses for a {$labels} query. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_query_clauses} | ||
| * | ||
| * @param array \$clauses { | ||
| * Associative array of SQL clause strings. | ||
| * | ||
| * @type string \$fields The SELECT fields. | ||
| * @type string \$join The JOIN clause. | ||
| * @type string \$where The WHERE clause. | ||
| * @type string \$orderby The ORDER BY clause. | ||
| * @type string \$limits The LIMIT clause. | ||
| * @type string \$groupby The GROUP BY clause. | ||
| * } | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| * @return array | ||
| */ | ||
| \$clauses = apply_filters_ref_array('{$prefix}_{$items}_query_clauses', [\$clauses, &\$query]); | ||
|
|
||
| /** | ||
| * Filters the columns to search when performing a {$labels} search. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_search_cols} | ||
| * | ||
| * @param string[] \$search_columns Array of column names to search. | ||
| * @param string \$search The search term. | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance. | ||
| * @return string[] | ||
| */ | ||
| \$search_columns = apply_filters('{$prefix}_{$items}_search_columns', \$search_columns, \$search, \$query); | ||
|
|
||
| /** | ||
| * Filters the found {$labels} after a query. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_the_items} | ||
| * | ||
| * @param object[] \$items The array of found {$label} objects. | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| * @return object[] | ||
| */ | ||
| \$items = apply_filters_ref_array('{$prefix}_the_{$items}', [\$items, &\$query]); | ||
|
|
||
| /** | ||
| * Filters a single {$label} item before it is inserted or updated in the database. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_filter_item} | ||
| * | ||
| * @param array \$item The item data as an associative array. | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| * @return array | ||
| */ | ||
| \$item = apply_filters_ref_array('{$prefix}_filter_{$item}_item', [\$item, &\$query]); | ||
|
|
||
| /** | ||
| * Filters the FOUND_ROWS() query for {$labels}. | ||
| * | ||
| * @since 2.0.0 | ||
| * @{$see_found_query} | ||
| * | ||
| * @param string \$sql The SQL query to count found rows. | ||
| * @param \\BerlinDB\\Database\\Query \$query The query instance (passed by reference). | ||
| * @return string | ||
| */ | ||
| \$sql = apply_filters_ref_array('{$prefix}_found_{$items}_query', [\$sql, &\$query]); | ||
|
|
||
| HOOK; | ||
| } | ||
|
|
||
| echo "\n// phpcs:enable\n"; | ||
|
|
||
| $content = ob_get_clean(); | ||
|
|
||
| file_put_contents($output_file, $content); | ||
|
|
||
| fwrite(STDERR, "Written to {$output_file}\n"); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg "file_put_contents" -n inc/documentation/generate-berlindb-hooks.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 127
Handle file_put_contents return value
file_put_contents() can return false, but the script always reports success. Check its return value, emit an error on failure, and exit with a non-zero status.
Suggested fix
$content = ob_get_clean();
-file_put_contents($output_file, $content);
+$_bytes = file_put_contents($output_file, $content);
+if ($_bytes === false) {
+ fwrite(STDERR, "Failed to write {$output_file}\n");
+ exit(1);
+}
fwrite(STDERR, "Written to {$output_file}\n");🤖 Prompt for AI Agents
In `@inc/documentation/generate-berlindb-hooks.php` around lines 351 - 545, The
script currently calls file_put_contents($output_file, $content) but always
prints a success message; modify the block around file_put_contents so you
capture its return value (e.g. $bytes = file_put_contents($output_file,
$content)), check for === false, and on failure write a clear error to STDERR
(including $output_file) and exit with a non-zero status (exit(1)); only print
the "Written to ..." message to STDERR when the write succeeded. Use the
existing symbols $output_file, $content, fwrite and STDERR to implement the
check and error path.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
PWYW fixes: - Add custom_amounts and pwyw_recurring to Cart in process_order() - Add missing pricing inputs to should_collect_payment() temporary cart - Remove v-bind:name suppression for PWYW duration fields in product edit - Update Product::is_recurring() to check pwyw_recurring_mode - Add wu_product_is_recurring filter for extensibility Settings API refactor: - Create save_setting() helper for DRY code - Add sanitize_setting_value() using Field API for proper sanitization - Add get_field_definition() to look up field definitions from Settings - Remove body_params from API logging to avoid storing sensitive data Grammar and text fixes: - "This products" → "These products" - "do not exist" → "does not exist" - Remove trailing space from "Force Synchronous Site Publication" Security improvements: - Add rel="noopener noreferrer" and esc_url() to privacy policy link Cleanup: - Delete stale inc/berlindb-dynamic-hooks.php duplicate - Fix changelog: separate 2.4.10 (released) from 2.4.11 (unreleased) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
inc/checkout/class-cart.php (1)
1698-1785:⚠️ Potential issue | 🟡 MinorEnsure minimum price is enforced even when no custom amount is provided.
If the suggested amount is below the minimum, the cart currently allows under‑minimum pricing. Clamp to the minimum (or error) to keep the rule consistent.🛠️ Proposed fix
- } else { - // Use suggested amount as default - $amount = $product->get_pwyw_suggested_amount(); - } + } else { + // Use suggested amount as default, but never below minimum + $amount = max( + $product->get_pwyw_suggested_amount(), + $product->get_pwyw_minimum_amount() + ); + }
🧹 Nitpick comments (2)
inc/admin-pages/class-product-edit-admin-page.php (1)
333-356: Consider adding validation that suggested price ≥ minimum price.The PWYW amount fields look good, but there's no client-side validation to ensure
pwyw_suggested_amountis greater than or equal topwyw_minimum_amount. An admin could accidentally set a suggested price below the minimum, which would confuse customers at checkout.You could add a Vue-based warning or validation in the wrapper attributes, or handle this server-side in
handle_save().inc/apis/class-settings-endpoint.php (1)
483-499: Avoid unused loop variable to satisfy PHPMD.
Usearray_keys()(or a named variable) to remove the unused$_.🛠️ Proposed fix
- foreach ($settings as $key => $_) { + foreach (array_keys($settings) as $key) { if ($this->is_sensitive_setting($key)) { unset($settings[ $key ]); } }
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
…-multisite into tweask-again # Conflicts: # assets/js/checkout.js
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@assets/js/checkout.js`:
- Around line 460-515: In create_order(), the try/catch wrapping a commented-out
history.pushState is unreachable; either re-enable the pushState call so the
catch can meaningfully handle failures or remove the entire try/catch block (and
the commented pushState) to satisfy the linter; update the wu_on_create_order
flow accordingly and ensure no leftover commented history.pushState remains in
the create_order method.
In `@inc/checkout/class-checkout.php`:
- Around line 1961-1999: The temporary Cart built in should_collect_payment()
omits state, city, membership_id, payment_id, auto_renew and cart_type which can
change tax, proration and recovery logic; update the Cart instantiation in
should_collect_payment() to pass these values from request_or_session (e.g.
'state' => $this->request_or_session('billing_state'), 'city' =>
$this->request_or_session('billing_city'), 'membership_id' =>
$this->request_or_session('membership_id'), 'payment_id' =>
$this->request_or_session('payment_id'), 'auto_renew' =>
$this->request_or_session('auto_renew', true), 'cart_type' =>
$this->request_or_session('cart_type', 'new')) so Cart->get_total() and
Cart->should_collect_payment() reflect real checkout state; alternatively expose
a filter in should_collect_payment() to allow callers to inject/modify the cart
payload before creating the Cart.
| create_order() { | ||
|
|
||
| /* | ||
| * Bail if there is no order summary to update. | ||
| */ | ||
| if (!jQuery('#wu-order-summary-content').length) { | ||
| if (! jQuery('#wu-order-summary-content').length) { | ||
|
|
||
| return; | ||
| return; | ||
|
|
||
| } // end if; | ||
| } // end if; | ||
|
|
||
| this.block(); | ||
| this.block(); | ||
|
|
||
| this.order = false; | ||
| this.order = false; | ||
|
|
||
| const that = this; | ||
| const that = this; | ||
|
|
||
| const _request = this.debounce(this.request); | ||
| const _request = this.debounce(this.request); | ||
|
|
||
| const data = { ...this.$data }; | ||
| const data = { ...this.$data }; | ||
|
|
||
| delete data.stored_templates; | ||
| delete data.state_list; | ||
| delete data.city_list; | ||
| delete data.labels; | ||
| delete data.stored_templates; | ||
| delete data.state_list; | ||
| delete data.city_list; | ||
| delete data.labels; | ||
|
|
||
| _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { | ||
| _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { | ||
|
|
||
| that.order = results.data.order; | ||
| that.order = results.data.order; | ||
|
|
||
| that.state_list = results.data.states; | ||
| that.state_list = results.data.states; | ||
|
|
||
| that.city_list = results.data.cities; | ||
| that.city_list = results.data.cities; | ||
|
|
||
| that.labels = results.data.labels; | ||
| that.labels = results.data.labels; | ||
|
|
||
| that.cart_type = results.data.order.type; | ||
| that.cart_type = results.data.order.type; | ||
|
|
||
| that.errors = results.data.order.errors; | ||
| that.errors = results.data.order.errors; | ||
|
|
||
| that.hooks.doAction('wu_on_create_order', that, results.data); | ||
| that.hooks.doAction('wu_on_create_order', that, results.data); | ||
|
|
||
| if (results.data.order.url) { | ||
| if (results.data.order.url) { | ||
|
|
||
| try { | ||
| try { | ||
|
|
||
| // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); | ||
| // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); | ||
|
|
||
| } catch (err) { | ||
| } catch (err) { | ||
|
|
||
| // eslint-disable-next-line no-console | ||
| console.warn('Browser does not support pushState.', err); | ||
| // eslint-disable-next-line no-console | ||
| console.warn('Browser does not support pushState.', err); | ||
|
|
||
| } // end try; | ||
| } // end try; | ||
|
|
||
| } // ed if; | ||
| } // ed if; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove unreachable try/catch block.
The try block is empty (pushState is commented), so the catch is unreachable per lint. Either re-enable pushState or remove the block to clear the error.
🧹 Minimal cleanup
- if (results.data.order.url) {
-
- try {
-
- // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url);
-
- } catch (err) {
-
- // eslint-disable-next-line no-console
- console.warn('Browser does not support pushState.', err);
-
- } // end try;
-
- } // ed if;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| create_order() { | |
| /* | |
| * Bail if there is no order summary to update. | |
| */ | |
| if (!jQuery('#wu-order-summary-content').length) { | |
| if (! jQuery('#wu-order-summary-content').length) { | |
| return; | |
| return; | |
| } // end if; | |
| } // end if; | |
| this.block(); | |
| this.block(); | |
| this.order = false; | |
| this.order = false; | |
| const that = this; | |
| const that = this; | |
| const _request = this.debounce(this.request); | |
| const _request = this.debounce(this.request); | |
| const data = { ...this.$data }; | |
| const data = { ...this.$data }; | |
| delete data.stored_templates; | |
| delete data.state_list; | |
| delete data.city_list; | |
| delete data.labels; | |
| delete data.stored_templates; | |
| delete data.state_list; | |
| delete data.city_list; | |
| delete data.labels; | |
| _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { | |
| _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { | |
| that.order = results.data.order; | |
| that.order = results.data.order; | |
| that.state_list = results.data.states; | |
| that.state_list = results.data.states; | |
| that.city_list = results.data.cities; | |
| that.city_list = results.data.cities; | |
| that.labels = results.data.labels; | |
| that.labels = results.data.labels; | |
| that.cart_type = results.data.order.type; | |
| that.cart_type = results.data.order.type; | |
| that.errors = results.data.order.errors; | |
| that.errors = results.data.order.errors; | |
| that.hooks.doAction('wu_on_create_order', that, results.data); | |
| that.hooks.doAction('wu_on_create_order', that, results.data); | |
| if (results.data.order.url) { | |
| if (results.data.order.url) { | |
| try { | |
| try { | |
| // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); | |
| // history.pushState({}, null, wu_checkout.baseurl + results.data.order.url); | |
| } catch (err) { | |
| } catch (err) { | |
| // eslint-disable-next-line no-console | |
| console.warn('Browser does not support pushState.', err); | |
| // eslint-disable-next-line no-console | |
| console.warn('Browser does not support pushState.', err); | |
| } // end try; | |
| } // end try; | |
| } // ed if; | |
| } // ed if; | |
| create_order() { | |
| /* | |
| * Bail if there is no order summary to update. | |
| */ | |
| if (! jQuery('#wu-order-summary-content').length) { | |
| return; | |
| } // end if; | |
| this.block(); | |
| this.order = false; | |
| const that = this; | |
| const _request = this.debounce(this.request); | |
| const data = { ...this.$data }; | |
| delete data.stored_templates; | |
| delete data.state_list; | |
| delete data.city_list; | |
| delete data.labels; | |
| _request('wu_create_order', this.filter_for_request(data, 'wu_create_order'), function (results) { | |
| that.order = results.data.order; | |
| that.state_list = results.data.states; | |
| that.city_list = results.data.cities; | |
| that.labels = results.data.labels; | |
| that.cart_type = results.data.order.type; | |
| that.errors = results.data.order.errors; | |
| that.hooks.doAction('wu_on_create_order', that, results.data); |
🧰 Tools
🪛 Biome (2.3.13)
[error] 508-513: This code is unreachable
(lint/correctness/noUnreachable)
🤖 Prompt for AI Agents
In `@assets/js/checkout.js` around lines 460 - 515, In create_order(), the
try/catch wrapping a commented-out history.pushState is unreachable; either
re-enable the pushState call so the catch can meaningfully handle failures or
remove the entire try/catch block (and the commented pushState) to satisfy the
linter; update the wu_on_create_order flow accordingly and ensure no leftover
commented history.pushState remains in the create_order method.
| /** | ||
| * Determines whether payment should be collected for the current checkout. | ||
| * | ||
| * Uses $this->order if available, otherwise builds a temporary Cart | ||
| * from the request/session data to check. | ||
| * | ||
| * @since 2.0.20 | ||
| * @return bool | ||
| */ | ||
| public function should_collect_payment(): bool { | ||
|
|
||
| if ($this->order) { | ||
| return $this->order->should_collect_payment(); | ||
| } | ||
|
|
||
| $products = $this->request_or_session('products', []); | ||
|
|
||
| if (empty($products)) { | ||
| return true; | ||
| } | ||
|
|
||
| try { | ||
| $cart = new Cart( | ||
| [ | ||
| 'products' => (array) $products, | ||
| 'country' => $this->request_or_session('billing_country'), | ||
| 'discount_code' => $this->request_or_session('discount_code'), | ||
| 'duration' => $this->request_or_session('duration'), | ||
| 'duration_unit' => $this->request_or_session('duration_unit'), | ||
| 'custom_amounts' => $this->request_or_session('custom_amounts', []), | ||
| 'pwyw_recurring' => $this->request_or_session('pwyw_recurring', []), | ||
| ] | ||
| ); | ||
|
|
||
| return $cart->should_collect_payment(); | ||
| } catch (\Throwable $e) { | ||
| return true; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# First, locate the Cart class
fd -t f "class-cart.php|Cart.php" inc/
# Check Cart's constructor signature
rg -n "public function __construct" inc/checkout/class-cart.php -A 20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 669
🏁 Script executed:
#!/bin/bash
# Read the full Cart constructor to see all parameters
sed -n '263,400p' inc/checkout/class-cart.php | cat -nRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 4457
🏁 Script executed:
#!/bin/bash
# Search for tax logic using state/city
rg -n -C5 "state|city" inc/checkout/class-cart.php | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2932
🏁 Script executed:
#!/bin/bash
# Find the should_collect_payment method in Cart
rg -n "should_collect_payment" inc/checkout/class-cart.php -A 30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2173
🏁 Script executed:
#!/bin/bash
# Check if is_free() and get_recurring_total() depend on taxes
rg -n "function is_free|function get_recurring_total" inc/checkout/class-cart.php -A 15Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 958
🏁 Script executed:
#!/bin/bash
# Check if get_total() includes taxes
rg -n "function get_total\(" inc/checkout/class-cart.php -A 20 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 678
🏁 Script executed:
#!/bin/bash
# Check if line_item get_total includes taxes
rg -n "class.*LineItem" inc/checkout/ -A 3Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Search for LineItem class
fd -t f "line" inc/checkout/ -type f
rg -n "get_total\(\)" inc/checkout/class-cart.php | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 813
🏁 Script executed:
#!/bin/bash
# Find where line_items are created and check their structure
rg -n "line_item.*get_total|->get_total" inc/checkout/class-cart.php -B 3 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1469
🏁 Script executed:
#!/bin/bash
# Search for where line_items array is built to understand the object type
rg -n "\$this->line_items\s*=|->line_items\[\]" inc/checkout/class-cart.php -B 2 -A 2 | head -80Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
#!/bin/bash
# Look for line_items initialization and use
rg -n "line_items" inc/checkout/class-cart.php | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1561
🏁 Script executed:
#!/bin/bash
# Search for where taxes are added to line items
rg -n "tax|->get_total" inc/checkout/class-cart.php | grep -i "tax\|recalculate"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2588
🏁 Script executed:
#!/bin/bash
# Check if membership_id, payment_id, auto_renew, cart_type affect the build_cart or should_collect_payment logic
rg -n "membership_id|payment_id|auto_renew|cart_type" inc/checkout/class-cart.php | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1970
🏁 Script executed:
#!/bin/bash
# Check what wu_get_applicable_tax_rates returns with empty state/city
rg -n "wu_get_applicable_tax_rates" --type-list 2>/dev/null || rg -rn "function wu_get_applicable_tax_rates" .Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 172
🏁 Script executed:
#!/bin/bash
# Get the full implementation of wu_get_applicable_tax_rates
rg -n "function wu_get_applicable_tax_rates" inc/functions/tax.php -A 30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 886
🏁 Script executed:
#!/bin/bash
# Verify what happens when state/city are empty strings in wu_get_applicable_tax_rates
sed -n '130,180p' inc/functions/tax.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 1170
State/city omission will miscalculate payment collection.
The temporary Cart constructed in should_collect_payment() omits state, city, membership_id, payment_id, auto_renew, and cart_type. These parameters directly affect cart calculations:
- Tax rates are determined by
wu_get_applicable_tax_rates($country, $state, $city)(Cart line 2490). Omitting state/city causes them to default to empty strings, bypassing state/city-specific tax rules. - Cart type determines setup fee and proration logic (Cart lines 1275, 2306, 2347). Defaulting to 'new' skips upgrade/downgrade calculations.
- membership_id/payment_id trigger recovery and upgrade workflows. Omitting these builds a fresh cart instead.
- auto_renew affects cart setup. Defaulting to
trueignores the actual customer preference.
Since should_collect_payment() depends on get_total() which includes calculated taxes, the decision to collect payment will be incorrect when taxes or prorations vary by location or cart type.
Pass the actual values from the request/session to align with real checkout state, or apply a filter to allow customization.
🤖 Prompt for AI Agents
In `@inc/checkout/class-checkout.php` around lines 1961 - 1999, The temporary Cart
built in should_collect_payment() omits state, city, membership_id, payment_id,
auto_renew and cart_type which can change tax, proration and recovery logic;
update the Cart instantiation in should_collect_payment() to pass these values
from request_or_session (e.g. 'state' =>
$this->request_or_session('billing_state'), 'city' =>
$this->request_or_session('billing_city'), 'membership_id' =>
$this->request_or_session('membership_id'), 'payment_id' =>
$this->request_or_session('payment_id'), 'auto_renew' =>
$this->request_or_session('auto_renew', true), 'cart_type' =>
$this->request_or_session('cart_type', 'new')) so Cart->get_total() and
Cart->should_collect_payment() reflect real checkout state; alternatively expose
a filter in should_collect_payment() to allow callers to inject/modify the cart
payload before creating the Cart.
Replace fragile UI-based wizard setup with WP-CLI/PHP fixture approach: - Add PHP fixtures for DB tables, products, checkout form, trial products, and verification scripts - Create 000-setup, 010-manual-checkout-flow, 020-free-trial-flow specs - Fix wpCli command (npx wp-env instead of npm run env) and add wpCliFile - Remove broken checkout commands and old specs that used vague selectors - Remove domain-mapping-roles and plugin specs (incompatible with wp-env) - Fix wizard.spec.js: handle variable button text on installer steps, add missing recommended-plugins step, reset custom login page on setup - Remove WooCommerce addon from core .wp-env.json test plugins - Add env:fix-perms script for Docker permission issues All 15 tests across 7 specs pass reliably on consecutive runs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
🤖 Fix all issues with AI agents
In `@tests/e2e/cypress/fixtures/setup-checkout-form.php`:
- Line 6: Replace the loose text search using
WP_Ultimo\Models\Checkout_Form::query([ 'search' => 'main-form', ... ]) with the
exact-slug helper wu_get_checkout_form_by_slug('main-form') to fetch the form by
slug, and when creating a form check the return of wp_insert_post for WP_Error
(and falsy values) before proceeding—if wp_insert_post returns a WP_Error or
false, handle/log/return the error similar to the existing form creation check
so failures are detected and handled.
In `@tests/e2e/cypress/fixtures/setup-tables.php`:
- Line 13: The call to update_network_option currently has a prohibited space
before the opening parenthesis; remove the space so the function name is
immediately followed by the opening parenthesis (i.e., change
update_network_option( null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, time() )
to use no space between update_network_option and the '('), and ensure arguments
remain the same (null, WP_Ultimo::NETWORK_OPTION_SETUP_FINISHED, time()) so
static analysis passes.
In `@tests/e2e/cypress/fixtures/setup-trial-product.php`:
- Around line 7-10: The calls to the product setter methods (product->set_name,
product->set_slug, product->set_amount, product->set_duration) contain
prohibited spaces inside the parentheses; remove the extra space after the
opening parenthesis and before the closing parenthesis for each call so they
read like set_name('Trial Plan'), set_slug('trial-plan'), set_amount(19.99),
set_duration(1) to satisfy the code style/analysis rules.
In `@tests/e2e/cypress/fixtures/verify-manual-checkout-results.php`:
- Around line 22-29: Replace the call to json_encode(...) with
wp_json_encode(...) in the echo statement that outputs the array of
$um_payment_status, $um_payment_gateway, $um_payment_total,
$um_membership_status, $um_site_count, and $um_site_type; ensure the array
structure is unchanged and only the encoder function name (json_encode ->
wp_json_encode) is updated so the fixture uses WordPress' JSON encoder.
In `@tests/e2e/cypress/fixtures/verify-trial-results.php`:
- Around line 20-25: Replace the direct use of json_encode() with WordPress'
wp_json_encode() in the code that outputs the trial verification array (the echo
json_encode([...]) call); update the echo statement to call wp_json_encode() so
it uses WP's JSON encoding and error handling while keeping the same array keys
('um_payment_status', 'um_membership_status', 'um_membership_trial_end',
'um_site_type').
In `@tests/e2e/cypress/integration/000-setup.spec.js`:
- Around line 54-64: Add a readback assertion for the 'force_publish_sites_sync'
setting in the test so both settings changed by the cy.wpCli call are verified:
after calling cy.wpCli with wu_save_setting('enable_email_verification', ...)
and wu_save_setting('force_publish_sites_sync', true) add another cy.wpCli
invocation that runs wu_get_setting('force_publish_sites_sync') and assert the
result stdout contains "1" or "true" (whichever the app returns) to confirm the
change; reference the existing cy.wpCli helper and the
wu_get_setting/wu_save_setting symbols to locate where to add the check in the
"Should disable email verification and enable sync site publish" test.
- Around line 24-31: The test currently only logs the wpCliFile output and
doesn't assert success; update the "Should create a test product/plan" test to
assert the command succeeded by checking result.code (or result.stdout) contains
a valid product ID and that productId is non-empty; locate the cy.wpCliFile(...)
call in the it("Should create a test product/plan", ...) block and after
extracting productId (const productId = result.stdout.trim()) add an assertion
(e.g., expect(result.code).to.equal(0) or expect(productId).to.match(/\d+/) /
expect(productId).to.not.be.empty) so failures fail the spec instead of only
being logged.
In `@tests/e2e/cypress/integration/020-free-trial-flow.spec.js`:
- Around line 13-31: The test currently calls
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false }) and ignores the result, which can hide failures;
update the then handler for cy.wpCliFile to assert the command succeeded and
returned a non-empty numeric productId (check result.code === 0 or result.stderr
is empty and result.stdout.trim() is a positive integer) and fail the test early
if not—use the existing productId variable name in the assertion and log to
preserve behavior.
In `@tests/e2e/cypress/support/commands/index.js`:
- Around line 16-22: The wpCliFile helper currently interpolates filePath into
the shell command unsafely; sanitize and properly quote it before calling
cy.exec: in wpCliFile, validate filePath (reject empty, absolute paths starting
with '/' or '\\', any segment of '..', or any characters like
backticks/newlines/quotes), normalize it (e.g., use path.posix.normalize and
ensure the normalized path does not climb above the plugin directory), then
construct containerPath from the sanitized relative path and pass a safely
quoted containerPath to cy.exec (escape any single quotes if you use
single-quote wrapping) so the shell invocation in the cy.exec call cannot be
broken or used for path traversal or command injection.
🧹 Nitpick comments (9)
tests/e2e/cypress/support/commands/index.js (2)
5-10: Return thecy.execchainable fromwpCli.
Line 5: without a return, callers can’t reliably chain or assert on the exec result.Proposed fix
Cypress.Commands.add("wpCli", (command, options = {}) => { - cy.exec(`npx wp-env run tests-cli wp ${command}`, { + return cy.exec(`npx wp-env run tests-cli wp ${command}`, { ...options, timeout: options.timeout || 60000, }); });
25-37: Return thecy.requestchainable fromloginByApi.
Line 25: returning enables chaining and explicit waiting/inspection of the response.Proposed fix
Cypress.Commands.add("loginByApi", (username, password) => { - cy.request({ + return cy.request({ method: "POST", url: "/wp-login.php", form: true, body: { log: username, pwd: password, "wp-submit": "Log In", redirect_to: "/wp-admin/", testcookie: 1, }, }); });package.json (1)
77-78: Pin the Alpine image tag used in env:fix-perms.Line 77 uses
alpine(latest), which is non-deterministic and can change behavior over time. Pin to a specific stable release branch such asalpine:3.22or better yet a patch tag likealpine:3.22.3(or image digest) to keepenv:start(Line 78) reproducible.tests/e2e/cypress/fixtures/setup-product.php (1)
13-15: Consider adding error handling for save() failure.The
save()method can return aWP_Erroron failure (per the docstring in the relevant code snippets). If save fails,get_id()may return null/0, potentially causing silent test failures.Proposed fix
-$product->save(); +$result = $product->save(); + +if ( is_wp_error( $result ) ) { + echo 'error:' . $result->get_error_message(); + return; +} echo $product->get_id();tests/e2e/cypress/fixtures/setup-trial-product.php (1)
16-18: Consider adding error handling for save() failure.Similar to
setup-product.php, ifsave()fails,get_id()may return null, leading to an invalid product ID being appended to the pricing table.Proposed fix
-$product->save(); - -$product_id = $product->get_id(); +$result = $product->save(); + +if ( is_wp_error( $result ) ) { + echo 'error:' . $result->get_error_message(); + return; +} + +$product_id = $product->get_id();tests/e2e/cypress/fixtures/setup-checkout-form.php (1)
31-40: Consider checkingwp_insert_postreturn value for errors.
wp_insert_postcan return aWP_Erroron failure. Without checking, a failed page insertion would save an error object as thedefault_registration_pagesetting.Proposed fix
$page_id = wp_insert_post( [ 'post_name' => 'register', 'post_title' => 'Register', 'post_content' => '[wu_checkout slug="main-form"]', 'post_status' => 'publish', 'post_type' => 'page', 'post_author' => 1, ] ); +if ( is_wp_error( $page_id ) ) { + echo 'error:' . $page_id->get_error_message(); + return; +} + wu_save_setting( 'default_registration_page', $page_id );tests/e2e/cypress/integration/wizard.spec.js (1)
77-80: Consider adding an explicit URL assertion before clicking.The
cy.url({ timeout: 120000 })on line 77 validates navigation, but for consistency with other steps, consider addingcy.assertPageUrl()to verify the full page context before interacting with the button.Proposed enhancement
/** * Steps: Recommended Plugins * May download plugins from wordpress.org via AJAX; allow extra time. */ cy.url({ timeout: 120000 }).should("include", "step=recommended-plugins"); + cy.assertPageUrl({ + pathname: "/wp-admin/network/admin.php", + page: page_name, + step: "recommended-plugins", + }); cy.get('button[data-testid="button-primary"]') .should("not.be.disabled") .click();tests/e2e/cypress/integration/010-manual-checkout-flow.spec.js (2)
1-11: Optional: share test-data helpers to reduce duplication.This same timestamp-based customer/site data pattern appears in the free-trial spec; a small helper would centralize future edits.
13-83: Make this spec self-sufficient instead of relying on earlier setup specs.This flow implicitly depends on the checkout form and manual gateway being preconfigured (likely by 000-setup). If this spec runs alone or in parallel, it can fail unexpectedly. Consider adding a local
before()that ensures the checkout form exists and the manual gateway is enabled (or at least verifies those preconditions).
| * Create a checkout form and registration page for e2e testing. | ||
| * Idempotent: skips creation if the form already exists. | ||
| */ | ||
| $existing = WP_Ultimo\Models\Checkout_Form::query( [ 'search' => 'main-form', 'number' => 1 ] ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, let's explore the repository structure
find . -type f -name "*.php" | grep -i checkout_form | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 128
🏁 Script executed:
# Look for the Checkout_Form model
find . -type f -path "*Models*" -name "*.php" | grep -i checkoutRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 128
🏁 Script executed:
# Check the structure of inc/database directory
ls -la inc/database/ 2>/dev/null || echo "Directory not found, searching..."
find . -type d -name "*checkout*" | head -10Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1142
🏁 Script executed:
# Find the setup-checkout-form.php file mentioned in the review
find . -type f -name "setup-checkout-form.php"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 133
🏁 Script executed:
# Let's examine the setup-checkout-form.php file
cat -n ./tests/e2e/cypress/fixtures/setup-checkout-form.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 1438
🏁 Script executed:
# Now let's look at the checkout-forms database directory
ls -la ./inc/database/checkout-forms/Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 523
🏁 Script executed:
# Search for the Checkout_Form model class
find . -type f -name "*.php" -path "*Models*" | xargs grep -l "class Checkout_Form"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 128
🏁 Script executed:
# Find the Checkout_Form model class
find . -type f -name "*.php" | xargs grep -l "class Checkout_Form" | grep -v TestRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 582
🏁 Script executed:
# Examine the Checkout_Form query class
cat -n ./inc/database/checkout-forms/class-checkout-form-query.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 2551
🏁 Script executed:
# Let's also check the schema to understand the structure
cat -n ./inc/database/checkout-forms/class-checkout-forms-schema.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 2856
🏁 Script executed:
# Now let's look at the Checkout_Form model
cat -n ./inc/models/class-checkout-form.php | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2310
🏁 Script executed:
# Let's find the Query base class to understand what parameters are supported
find . -type f -path "*Database/Engine*" -name "*.php" | head -10Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Search for the Query class definition
find . -type f -name "*.php" -path "*Engine*" | xargs grep -l "class Query"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 65
🏁 Script executed:
# Search for the Query engine class
find . -type f -name "*.php" | xargs grep -l "class Query" | grep -i engineRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 118
🏁 Script executed:
# Let's check the engine directory structure
ls -la ./inc/database/engine/Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 646
🏁 Script executed:
# Look for query parameter handling - search for how 'search' parameter is handled
rg "search" ./inc/database/engine/ -A 3 -B 3Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's examine the Query base class
cat -n ./inc/database/engine/class-query.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 3525
🏁 Script executed:
# Search for how 'search' parameter is handled in the entire database engine
rg "search" ./inc/database/engine/*.php -A 2 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's search for query parameter handling more broadly
rg "'search'" . --type php -A 2 -B 2 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2542
🏁 Script executed:
# Let's search for how other models query by slug
rg "slug" ./inc/models/ --type php -B 2 -A 2 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2774
🏁 Script executed:
# Search for other usage of query() method with different parameters
rg "::query\(" . --type php -A 1 | grep -E "(slug|search)" | head -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 230
🏁 Script executed:
# Let's check if wp_insert_post error handling is addressed elsewhere in the codebase
rg "wp_insert_post" . --type php -A 3 | head -60Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 4579
🏁 Script executed:
# Let's check if we can query by slug directly - look for other query examples in tests
rg "::query\(" ./tests --type php -A 2 | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2937
🏁 Script executed:
# Let's search for BerlinDB documentation or see how direct field queries work
rg "->query" ./inc --type php -A 1 | grep -E "slug|name|id" -A 1 | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 485
🏁 Script executed:
# Check the specific test file for Checkout_Form to see how queries are tested
cat -n ./tests/WP_Ultimo/Models/Checkout_Form_Test.php | head -100Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 3660
🏁 Script executed:
# Let's check how BerlinDB or the Query class handles direct field parameters
# Search for examples of direct field queries in the codebase
rg "query\(\s*\[\s*'" ./inc --type php -A 2 | head -60Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's look for wp_error checking patterns in the codebase for wp_insert_post
rg "is_wp_error.*wp_insert_post\|wp_insert_post.*is_wp_error" . --type php -B 2 -A 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's verify if direct field queries are used elsewhere
rg "query\(\s*\[\s*'(name|slug|id)'" ./inc --type php -A 1Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's check the parent class BerlinDB more carefully - look for how query parameters are handled
rg "search.*searchable\|searchable.*search" ./inc/database/engine/ -A 2 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Look at the schema to understand which columns are searchable
rg "searchable" ./inc/database/checkout-forms/ -A 2 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 964
🏁 Script executed:
# Let's check if BerlinDB documentation or comments mention parameter handling
rg "search\|slug" ./inc/database/checkout-forms/class-checkout-form-query.php -B 3 -A 3Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Search for how query methods handle field-specific parameters in other models
rg "query.*slug\|slug.*query" ./inc/functions/ --type php -B 2 -A 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's look at how wp_insert_post results are used in the file
cat -n ./tests/e2e/cypress/fixtures/setup-checkout-form.php | tail -20Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 636
🏁 Script executed:
# Search for other E2E fixtures that use wp_insert_post to see if they check for errors
rg "wp_insert_post" ./tests/e2e/cypress/fixtures/ -A 4 -B 1 | head -50Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 547
🏁 Script executed:
# Let's check if there's any documentation or comments in BerlinDB integration
find . -type f -name "*.php" | xargs grep -l "BerlinDB" | head -5Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 270
🏁 Script executed:
# Let's check if direct field matching with slug works by looking at BerlinDB usage
rg "column_defaults\|get_column" ./inc/database/engine/ -A 5 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 63
🏁 Script executed:
# Let's understand how BerlinDB supports field-specific queries
# Check if there are examples of __in or direct field parameters
rg "'[a-z_]+__in'" ./inc --type php -A 1 | head -30Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1493
🏁 Script executed:
# Let's also check if the concern about search matching multiple fields is valid
# by testing a real scenario - see if there are multiple checkoutforms with similar names
rg "Checkout_Form" ./inc/functions/ --type php -A 5 -B 2 | head -40Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 2210
🏁 Script executed:
# Let's look at how BerlinDB query actually works by checking documentation in code comments
cat -n ./inc/database/engine/class-query.php | grep -A 20 "public function\|@param\|@return"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 1744
🏁 Script executed:
# I found a reference to wu_get_checkout_form_by_slug! Let's see the implementation
cat -n ./inc/functions/checkout-form.php | grep -A 10 "wu_get_checkout_form_by_slug"Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 779
🏁 Script executed:
# Let's see the complete checkout-form.php file
cat -n ./inc/functions/checkout-form.phpRepository: Ultimate-Multisite/ultimate-multisite
Length of output: 6703
🏁 Script executed:
# Check if is_wp_error is used anywhere in E2E fixtures
rg "is_wp_error" ./tests/e2e/cypress/fixtures/ -A 2 -B 2Repository: Ultimate-Multisite/ultimate-multisite
Length of output: 477
Use dedicated slug lookup function and add error handling for wp_insert_post.
The 'search' => 'main-form' performs a text search which may match unintended forms. Use the existing wu_get_checkout_form_by_slug('main-form') function for exact slug matching. Additionally, wp_insert_post() can return a WP_Error on failure but the code doesn't check for this; add error handling similar to the form creation check above.
Proposed fix
-$existing = WP_Ultimo\Models\Checkout_Form::query( [ 'search' => 'main-form', 'number' => 1 ] );
+$existing = wu_get_checkout_form_by_slug( 'main-form' );
-if ( $existing ) {
- $form = $existing[0];
+if ( $existing ) {
+ $form = $existing;
$page_id = wu_get_setting( 'default_registration_page', 0 );
echo 'form:' . $form->get_id() . ',page:' . $page_id;
return;
}
$page_id = wp_insert_post( [...] );
+
+if ( is_wp_error( $page_id ) ) {
+ echo 'error:' . $page_id->get_error_message();
+ return;
+}🤖 Prompt for AI Agents
In `@tests/e2e/cypress/fixtures/setup-checkout-form.php` at line 6, Replace the
loose text search using WP_Ultimo\Models\Checkout_Form::query([ 'search' =>
'main-form', ... ]) with the exact-slug helper
wu_get_checkout_form_by_slug('main-form') to fetch the form by slug, and when
creating a form check the return of wp_insert_post for WP_Error (and falsy
values) before proceeding—if wp_insert_post returns a WP_Error or false,
handle/log/return the error similar to the existing form creation check so
failures are detected and handled.
| it("Should create a test product/plan", () => { | ||
| cy.wpCliFile("tests/e2e/cypress/fixtures/setup-product.php", { | ||
| failOnNonZeroExit: false, | ||
| }).then((result) => { | ||
| const productId = result.stdout.trim(); | ||
| cy.log(`Created test product with ID: ${productId}`); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assert product creation success.
The fixture result is only logged; add a quick check to catch failures early.
✅ Suggested guard
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
+ expect(productId, "test product id").to.not.equal("");
cy.log(`Created test product with ID: ${productId}`);
});🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/000-setup.spec.js` around lines 24 - 31, The
test currently only logs the wpCliFile output and doesn't assert success; update
the "Should create a test product/plan" test to assert the command succeeded by
checking result.code (or result.stdout) contains a valid product ID and that
productId is non-empty; locate the cy.wpCliFile(...) call in the it("Should
create a test product/plan", ...) block and after extracting productId (const
productId = result.stdout.trim()) add an assertion (e.g.,
expect(result.code).to.equal(0) or expect(productId).to.match(/\d+/) /
expect(productId).to.not.be.empty) so failures fail the spec instead of only
being logged.
| it("Should disable email verification and enable sync site publish", () => { | ||
| cy.wpCli( | ||
| "eval \"wu_save_setting('enable_email_verification', 'never'); wu_save_setting('force_publish_sites_sync', true);\"" | ||
| ); | ||
|
|
||
| cy.wpCli( | ||
| "eval \"echo wu_get_setting('enable_email_verification', 'always');\"" | ||
| ).then((result) => { | ||
| expect(result.stdout).to.contain("never"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also verify force_publish_sites_sync is actually set.
You set two settings but only assert one; add a quick readback for force_publish_sites_sync to avoid silent regressions.
✅ Suggested check
cy.wpCli(
"eval \"wu_save_setting('enable_email_verification', 'never'); wu_save_setting('force_publish_sites_sync', true);\""
);
cy.wpCli(
"eval \"echo wu_get_setting('enable_email_verification', 'always');\""
).then((result) => {
expect(result.stdout).to.contain("never");
});
+
+ cy.wpCli(
+ "eval \"echo wu_get_setting('force_publish_sites_sync', false) ? 'true' : 'false';\""
+ ).then((result) => {
+ expect(result.stdout).to.contain("true");
+ });🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/000-setup.spec.js` around lines 54 - 64, Add a
readback assertion for the 'force_publish_sites_sync' setting in the test so
both settings changed by the cy.wpCli call are verified: after calling cy.wpCli
with wu_save_setting('enable_email_verification', ...) and
wu_save_setting('force_publish_sites_sync', true) add another cy.wpCli
invocation that runs wu_get_setting('force_publish_sites_sync') and assert the
result stdout contains "1" or "true" (whichever the app returns) to confirm the
change; reference the existing cy.wpCli helper and the
wu_get_setting/wu_save_setting symbols to locate where to add the check in the
"Should disable email verification and enable sync site publish" test.
| before(() => { | ||
| cy.loginByForm( | ||
| Cypress.env("admin").username, | ||
| Cypress.env("admin").password | ||
| ); | ||
|
|
||
| // Enable trial without payment method | ||
| cy.wpCli( | ||
| "eval \"wu_save_setting('allow_trial_without_payment_method', true);\"" | ||
| ); | ||
|
|
||
| // Create the trial product | ||
| cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", { | ||
| failOnNonZeroExit: false, | ||
| }).then((result) => { | ||
| const productId = result.stdout.trim(); | ||
| cy.log(`Created trial product with ID: ${productId}`); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fail fast if trial product creation fails.
With failOnNonZeroExit: false and no stdout check, a fixture failure can be silently ignored, leading to flaky downstream errors. Add a simple assertion on the returned ID (or stderr) to surface failures early.
✅ Suggested guard
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false,
}).then((result) => {
const productId = result.stdout.trim();
+ expect(productId, "trial product id").to.not.equal("");
cy.log(`Created trial product with ID: ${productId}`);
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| before(() => { | |
| cy.loginByForm( | |
| Cypress.env("admin").username, | |
| Cypress.env("admin").password | |
| ); | |
| // Enable trial without payment method | |
| cy.wpCli( | |
| "eval \"wu_save_setting('allow_trial_without_payment_method', true);\"" | |
| ); | |
| // Create the trial product | |
| cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", { | |
| failOnNonZeroExit: false, | |
| }).then((result) => { | |
| const productId = result.stdout.trim(); | |
| cy.log(`Created trial product with ID: ${productId}`); | |
| }); | |
| }); | |
| before(() => { | |
| cy.loginByForm( | |
| Cypress.env("admin").username, | |
| Cypress.env("admin").password | |
| ); | |
| // Enable trial without payment method | |
| cy.wpCli( | |
| "eval \"wu_save_setting('allow_trial_without_payment_method', true);\"" | |
| ); | |
| // Create the trial product | |
| cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", { | |
| failOnNonZeroExit: false, | |
| }).then((result) => { | |
| const productId = result.stdout.trim(); | |
| expect(productId, "trial product id").to.not.equal(""); | |
| cy.log(`Created trial product with ID: ${productId}`); | |
| }); | |
| }); |
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/integration/020-free-trial-flow.spec.js` around lines 13 -
31, The test currently calls
cy.wpCliFile("tests/e2e/cypress/fixtures/setup-trial-product.php", {
failOnNonZeroExit: false }) and ignores the result, which can hide failures;
update the then handler for cy.wpCliFile to assert the command succeeded and
returned a non-empty numeric productId (check result.code === 0 or result.stderr
is empty and result.stdout.trim() is a positive integer) and fail the test early
if not—use the existing productId variable name in the assertion and log to
preserve behavior.
| Cypress.Commands.add("wpCliFile", (filePath, options = {}) => { | ||
| const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`; | ||
|
|
||
| cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, { | ||
| ...options, | ||
| timeout: options.timeout || 60000, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate and quote filePath before shell execution.
Line 16-20: filePath is interpolated into a shell command and could include .. or quotes that escape the plugin root or break the command.
Proposed fix
Cypress.Commands.add("wpCliFile", (filePath, options = {}) => {
- const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`;
+ if (filePath.includes("..")) {
+ throw new Error("filePath must stay within the plugin root");
+ }
+ const containerPath =
+ `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`.replace(/"/g, '\\"');
- cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, {
+ return cy.exec(`npx wp-env run tests-cli wp eval-file "${containerPath}"`, {
...options,
timeout: options.timeout || 60000,
});
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| Cypress.Commands.add("wpCliFile", (filePath, options = {}) => { | |
| const containerPath = `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`; | |
| cy.exec(`npx wp-env run tests-cli wp eval-file ${containerPath}`, { | |
| ...options, | |
| timeout: options.timeout || 60000, | |
| }); | |
| Cypress.Commands.add("wpCliFile", (filePath, options = {}) => { | |
| if (filePath.includes("..")) { | |
| throw new Error("filePath must stay within the plugin root"); | |
| } | |
| const containerPath = | |
| `/var/www/html/wp-content/plugins/ultimate-multisite/${filePath}`.replace(/"/g, '\\"'); | |
| return cy.exec(`npx wp-env run tests-cli wp eval-file "${containerPath}"`, { | |
| ...options, | |
| timeout: options.timeout || 60000, | |
| }); | |
| }); |
🤖 Prompt for AI Agents
In `@tests/e2e/cypress/support/commands/index.js` around lines 16 - 22, The
wpCliFile helper currently interpolates filePath into the shell command
unsafely; sanitize and properly quote it before calling cy.exec: in wpCliFile,
validate filePath (reject empty, absolute paths starting with '/' or '\\', any
segment of '..', or any characters like backticks/newlines/quotes), normalize it
(e.g., use path.posix.normalize and ensure the normalized path does not climb
above the plugin directory), then construct containerPath from the sanitized
relative path and pass a safely quoted containerPath to cy.exec (escape any
single quotes if you use single-quote wrapping) so the shell invocation in the
cy.exec call cannot be broken or used for path traversal or command injection.
Apply WordPress coding standards: escape echo output with esc_html(), use wp_json_encode() instead of json_encode(), fix spacing in function calls and arrays. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
…rror handling - Reference actual spec files (000-setup, 010-manual-checkout-flow, 020-free-trial-flow) instead of non-existent ones (checkout-registration, setup-wizard-complete, etc.) - Remove invalid Cypress CLI flags (--video, --screenshot-on-failure, --record, --reporter-options) that caused 'unknown option' errors - Remove continue-on-error from test steps so failures properly fail the job - Remove redundant 'Fail job if tests failed' step Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
🔨 Build Complete - Ready for Testing!📦 Download Build Artifact (Recommended)Download the zip build, upload to WordPress and test:
🌐 Test in WordPress Playground (Very Experimental)Click the link below to instantly test this PR in your browser - no installation needed! Login credentials: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@inc/class-ajax.php`:
- Around line 112-115: The current condition overwrites a user-provided
$args['query']['number']; change the logic so the user-provided value is
preserved by copying $args['query']['number'] into $args['number'] when it's
present: check ! empty($args['query']['number']) and set $args['number'] =
$args['query']['number']; alternatively, if the intent was to set a default only
when query.number is missing, invert the condition to if (
empty($args['query']['number']) ) and set $args['query']['number'] =
$args['number'] — update the code around the $args['query']['number'] /
$args['number'] handling accordingly.
🧹 Nitpick comments (1)
inc/documentation/berlindb-dynamic-hooks.php (1)
143-157: Minor grammar issues in generated docblocks — fix in the generator script.Several auto-generated descriptions have awkward phrasing that would improve documentation readability:
- Line 144: "the has trialed of a customer" → "the has_trialed flag of a customer"
- Line 159: "the vip of a customer" → "the VIP status of a customer"
- Line 349: "The previous value value" → "The previous value" (duplicate word)
- Lines 386, 538, 568, 842: "the active of a..." → "the active status of a..."
- Line 523: "the domain of a domain" → "the domain name of a domain record"
- Lines 690, 705: "a event" → "an event"
Since this file should not be edited manually, consider updating
inc/documentation/generate-berlindb-hooks.phpto improve the humanization logic for these edge cases.
| // Number can be in the query array or it's own. Code uses both. | ||
| if (! empty($args['query']['number'])) { | ||
| $args['query']['number'] = $args['number']; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Logic appears inverted: user-provided query.number is overwritten instead of preserved.
The condition ! empty($args['query']['number']) triggers when the user has provided a value, then overwrites it with $args['number']. This discards user input rather than synchronizing it.
If the intent is for query.number to override args.number:
-// Number can be in the query array or it's own. Code uses both.
-if (! empty($args['query']['number'])) {
- $args['query']['number'] = $args['number'];
-}
+// Allow query.number to override the top-level number if provided.
+if (! empty($args['query']['number'])) {
+ $args['number'] = $args['query']['number'];
+}Alternatively, if the intent is to set a default when not provided:
-if (! empty($args['query']['number'])) {
+if (empty($args['query']['number'])) {
$args['query']['number'] = $args['number'];
},
🤖 Prompt for AI Agents
In `@inc/class-ajax.php` around lines 112 - 115, The current condition overwrites
a user-provided $args['query']['number']; change the logic so the user-provided
value is preserved by copying $args['query']['number'] into $args['number'] when
it's present: check ! empty($args['query']['number']) and set $args['number'] =
$args['query']['number']; alternatively, if the intent was to set a default only
when query.number is missing, invert the condition to if (
empty($args['query']['number']) ) and set $args['query']['number'] =
$args['number'] — update the code around the $args['query']['number'] /
$args['number'] handling accordingly.
and a few other tweaks
closes #330
Summary by CodeRabbit
New Features
Bug Fixes
Improvements
Documentation
Tests